Mini Apps: Support fullscreen (#5230)

This commit is contained in:
Alexander Zinchuk 2024-11-27 20:34:31 +04:00
parent 9dca147164
commit a992820d8a
17 changed files with 627 additions and 85 deletions

View File

@ -194,6 +194,7 @@ export async function requestWebView({
theme, theme,
sendAs, sendAs,
isFromBotMenu, isFromBotMenu,
isFullscreen,
}: { }: {
isSilent?: boolean; isSilent?: boolean;
peer: ApiPeer; peer: ApiPeer;
@ -204,6 +205,7 @@ export async function requestWebView({
theme?: ApiThemeParameters; theme?: ApiThemeParameters;
sendAs?: ApiPeer; sendAs?: ApiPeer;
isFromBotMenu?: boolean; isFromBotMenu?: boolean;
isFullscreen?: boolean;
}) { }) {
const result = await invokeRequest(new GramJs.messages.RequestWebView({ const result = await invokeRequest(new GramJs.messages.RequestWebView({
silent: isSilent || undefined, silent: isSilent || undefined,
@ -215,6 +217,7 @@ export async function requestWebView({
fromBotMenu: isFromBotMenu || undefined, fromBotMenu: isFromBotMenu || undefined,
platform: WEB_APP_PLATFORM, platform: WEB_APP_PLATFORM,
replyTo: replyInfo && buildInputReplyTo(replyInfo), replyTo: replyInfo && buildInputReplyTo(replyInfo),
fullscreen: isFullscreen ? true : undefined,
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
})); }));
@ -222,6 +225,7 @@ export async function requestWebView({
return { return {
url: result.url, url: result.url,
queryId: result.queryId?.toString(), queryId: result.queryId?.toString(),
isFullScreen: Boolean(result.fullscreen),
}; };
} }
@ -232,17 +236,20 @@ export async function requestMainWebView({
peer, peer,
bot, bot,
startParam, startParam,
mode,
theme, theme,
}: { }: {
peer: ApiPeer; peer: ApiPeer;
bot: ApiUser; bot: ApiUser;
startParam?: string; startParam?: string;
mode?: string;
theme?: ApiThemeParameters; theme?: ApiThemeParameters;
}) { }) {
const result = await invokeRequest(new GramJs.messages.RequestMainWebView({ const result = await invokeRequest(new GramJs.messages.RequestMainWebView({
peer: buildInputPeer(peer.id, peer.accessHash), peer: buildInputPeer(peer.id, peer.accessHash),
bot: buildInputPeer(bot.id, bot.accessHash), bot: buildInputPeer(bot.id, bot.accessHash),
startParam, startParam,
fullscreen: mode === 'fullscreen' || undefined,
themeParams: theme ? buildInputThemeParams(theme) : undefined, themeParams: theme ? buildInputThemeParams(theme) : undefined,
platform: WEB_APP_PLATFORM, platform: WEB_APP_PLATFORM,
})); }));
@ -254,6 +261,7 @@ export async function requestMainWebView({
return { return {
url: result.url, url: result.url,
queryId: result.queryId?.toString(), queryId: result.queryId?.toString(),
isFullscreen: Boolean(result.fullscreen),
}; };
} }
@ -310,12 +318,14 @@ export async function requestAppWebView({
peer, peer,
app, app,
startParam, startParam,
mode,
theme, theme,
isWriteAllowed, isWriteAllowed,
}: { }: {
peer: ApiPeer; peer: ApiPeer;
app: ApiBotApp; app: ApiBotApp;
startParam?: string; startParam?: string;
mode?: string;
theme?: ApiThemeParameters; theme?: ApiThemeParameters;
isWriteAllowed?: boolean; isWriteAllowed?: boolean;
}) { }) {
@ -326,9 +336,10 @@ export async function requestAppWebView({
themeParams: theme ? buildInputThemeParams(theme) : undefined, themeParams: theme ? buildInputThemeParams(theme) : undefined,
platform: WEB_APP_PLATFORM, platform: WEB_APP_PLATFORM,
writeAllowed: isWriteAllowed || undefined, writeAllowed: isWriteAllowed || undefined,
fullscreen: mode === 'fullscreen' || undefined,
})); }));
return result?.url; return { url: result?.url, isFullscreen: Boolean(result?.fullscreen) };
} }
export function prolongWebView({ export function prolongWebView({

View File

@ -61,7 +61,7 @@ const MinimizedWebAppModal = ({
}); });
const handleExpandClick = useLastCallback(() => { const handleExpandClick = useLastCallback(() => {
changeWebAppModalState(); changeWebAppModalState({ state: 'maximized' });
}); });
if (!isMinimizedState) return undefined; if (!isMinimizedState) return undefined;

View File

@ -58,12 +58,21 @@
.multi-tab { .multi-tab {
:global { :global {
.modal-dialog { .modal-dialog {
height: min(42.5rem, 85vh); width: 100%;
height: 100%;
max-height: min(42.5rem, 85vh);
max-width: 26.25rem; max-width: 26.25rem;
background-color: var(--color-web-app-browser); background-color: var(--color-web-app-browser);
backdrop-filter: blur(1.5625rem); 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); box-shadow: var(--modal-shadow);
} }
@ -105,13 +114,26 @@
:global { :global {
.modal-dialog { .modal-dialog {
cursor: grab !important; cursor: grab !important;
width: 300px; max-width: 300px;
height: 2.5rem; max-height: 2.5rem;
min-width: 0; min-width: 0;
} }
} }
} }
.fullScreen {
:global {
.modal-dialog {
max-width: 100%;
max-height: 100%;
border-radius: 0;
}
.modal-content {
border-radius: 0;
}
}
}
.tabs { .tabs {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -20,6 +20,7 @@ import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
import { getColorLuma } from '../../../util/colors'; import { getColorLuma } from '../../../util/colors';
import { hexToRgb } from '../../../util/switchTheme'; import { hexToRgb } from '../../../util/switchTheme';
import windowSize from '../../../util/windowSize';
import useInterval from '../../../hooks/schedulers/useInterval'; import useInterval from '../../../hooks/schedulers/useInterval';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
@ -89,12 +90,16 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const minimizedStateSize = useMemo(() => { const minimizedStateSize = useMemo(() => {
return { width: 300, height: 40 }; return { width: 300, height: 40 };
}, []); }, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [getFrameSize, setFrameSize] = useSignal( const [getFrameSize, setFrameSize] = useSignal(
{ width: maximizedStateSize.width, height: maximizedStateSize.height - minimizedStateSize.height }, { width: maximizedStateSize.width, height: maximizedStateSize.height - minimizedStateSize.height },
); );
function getSize() { function getSize() {
return modal?.modalState === 'maximized' ? maximizedStateSize : minimizedStateSize; if (modal?.modalState === 'fullScreen') return windowSize.get();
if (modal?.modalState === 'maximized') return maximizedStateSize;
return minimizedStateSize;
} }
const { const {
@ -118,6 +123,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const isOpen = modal?.isModalOpen || false; const isOpen = modal?.isModalOpen || false;
const isMaximizedState = modal?.modalState === 'maximized'; const isMaximizedState = modal?.modalState === 'maximized';
const isMinimizedState = modal?.modalState === 'minimized';
const isFullScreen = modal?.modalState === 'fullScreen';
const supportMultiTabMode = !isMobile; const supportMultiTabMode = !isMobile;
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -151,14 +158,24 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const containerElement = ref.current; const containerElement = ref.current;
useEffect(() => { useEffect(() => {
setIsDraggingEnabled(Boolean(supportMultiTabMode && headerElement && containerElement)); setIsDraggingEnabled(Boolean(supportMultiTabMode && headerElement && containerElement && !isFullScreen));
}, [supportMultiTabMode, headerElement, containerElement]); }, [supportMultiTabMode, headerElement, containerElement, isFullScreen]);
useEffect(() => {
changeWebAppModalState({ state: 'maximized' });
}, [supportMultiTabMode]);
const { const {
isDragging, isDragging,
style: draggableStyle, style: draggableStyle,
size, size,
} = useDraggable(ref, headerRef, isDraggingEnabled, getSize()); } = useDraggable(
ref,
headerRef,
isDraggingEnabled,
getSize(),
isFullScreen,
);
const currentSize = size || getSize(); const currentSize = size || getSize();
@ -170,7 +187,16 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const height = currentHeight - minimizedStateSize.height; const height = currentHeight - minimizedStateSize.height;
setFrameSize({ width: currentWidth, 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 oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
@ -275,7 +301,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
}); });
const handleCollapseClick = useLastCallback(() => { const handleCollapseClick = useLastCallback(() => {
changeWebAppModalState(); changeWebAppModalState({ state: 'minimized' });
}); });
const handleOpenMoreAppsTabClick = useLastCallback(() => { const handleOpenMoreAppsTabClick = useLastCallback(() => {
@ -497,7 +523,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
useHorizontalScroll(containerRef, !isOpen || !isMaximizedState || !(containerRef.current)); useHorizontalScroll(containerRef, !isOpen || isMinimizedState || !(containerRef.current));
function renderTabs() { function renderTabs() {
return ( return (
@ -606,7 +632,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
className={buildClassName( className={buildClassName(
styles.root, styles.root,
supportMultiTabMode && styles.multiTab, supportMultiTabMode && styles.multiTab,
!isMaximizedState && styles.minimized, isMinimizedState && styles.minimized,
isFullScreen && styles.fullScreen,
)} )}
dialogStyle={supportMultiTabMode ? draggableStyle : undefined} dialogStyle={supportMultiTabMode ? draggableStyle : undefined}
isOpen={isOpen} isOpen={isOpen}
@ -617,6 +644,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
noBackdrop noBackdrop
noBackdropClose noBackdropClose
> >
{isFullScreen && renderMoreMenu()}
{openedWebApps && sessionKeys?.map((key) => ( {openedWebApps && sessionKeys?.map((key) => (
<WebAppModalTabContent <WebAppModalTabContent
key={key} key={key}
@ -625,7 +653,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
registerReloadFrameCallback={registerReloadFrameCallback} registerReloadFrameCallback={registerReloadFrameCallback}
webApp={openedWebApps[key]} webApp={openedWebApps[key]}
isDragging={isDragging} isDragging={isDragging}
frameSize={supportMultiTabMode ? getFrameSize() : undefined} onContextMenuButtonClick={handleContextMenu}
isMultiTabSupported={supportMultiTabMode} isMultiTabSupported={supportMultiTabMode}
/> />
))} ))}

View File

@ -1,9 +1,12 @@
@use "../../../styles/mixins";
.root { .root {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.multi-tab { .multi-tab {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0; padding: 0;
@ -157,3 +160,124 @@
padding-left: 2rem; 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;
}

View File

@ -6,7 +6,7 @@ import React, {
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types'; 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 { ThemeKey } from '../../../types';
import type { PopupOptions, WebAppInboundEvent, WebAppOutboundEvent } from '../../../types/webapp'; import type { PopupOptions, WebAppInboundEvent, WebAppOutboundEvent } from '../../../types/webapp';
@ -23,18 +23,22 @@ import { callApi } from '../../../api/gramjs';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
import useFullscreen, { checkIfFullscreen } from '../../../hooks/window/useFullscreen';
import usePopupLimit from './hooks/usePopupLimit'; import usePopupLimit from './hooks/usePopupLimit';
import useWebAppFrame from './hooks/useWebAppFrame'; import useWebAppFrame from './hooks/useWebAppFrame';
import Icon from '../../common/icons/Icon';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog'; import ConfirmDialog from '../../ui/ConfirmDialog';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import Transition from '../../ui/Transition';
import styles from './WebAppModalTabContent.module.scss'; import styles from './WebAppModalTabContent.module.scss';
@ -53,6 +57,7 @@ export type OwnProps = {
webApp?: WebApp; webApp?: WebApp;
registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void; registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void;
registerReloadFrameCallback: (callback: (url: string) => void) => void; registerReloadFrameCallback: (callback: (url: string) => void) => void;
onContextMenuButtonClick: (e: React.MouseEvent) => void;
isDragging?: boolean; isDragging?: boolean;
frameSize?: { width: number; height: number }; frameSize?: { width: number; height: number };
isMultiTabSupported? : boolean; isMultiTabSupported? : boolean;
@ -65,15 +70,17 @@ type StateProps = {
theme?: ThemeKey; theme?: ThemeKey;
isPaymentModalOpen?: boolean; isPaymentModalOpen?: boolean;
paymentStatus?: TabState['payment']['status']; paymentStatus?: TabState['payment']['status'];
isMaximizedState: boolean; modalState?: WebAppModalStateType;
}; };
const NBSP = '\u00A0'; const NBSP = '\u00A0';
const MAIN_BUTTON_ANIMATION_TIME = 250; const MAIN_BUTTON_ANIMATION_TIME = 250;
const ANIMATION_WAIT = 400; const ANIMATION_WAIT = 400;
const COLLAPSING_WAIT = 350;
const POPUP_SEQUENTIAL_LIMIT = 3; const POPUP_SEQUENTIAL_LIMIT = 3;
const POPUP_RESET_DELAY = 2000; // 2s const POPUP_RESET_DELAY = 2000; // 2s
const APP_NAME_DISPLAY_DURATION = 3800;
const SANDBOX_ATTRIBUTES = [ const SANDBOX_ATTRIBUTES = [
'allow-scripts', 'allow-scripts',
'allow-same-origin', 'allow-same-origin',
@ -99,9 +106,10 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
registerSendEventCallback, registerSendEventCallback,
registerReloadFrameCallback, registerReloadFrameCallback,
isDragging, isDragging,
isMaximizedState, modalState,
frameSize, frameSize,
isMultiTabSupported, isMultiTabSupported,
onContextMenuButtonClick,
}) => { }) => {
const { const {
closeActiveWebApp, closeActiveWebApp,
@ -113,6 +121,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
sharePhoneWithBot, sharePhoneWithBot,
updateWebApp, updateWebApp,
resetPaymentStatus, resetPaymentStatus,
changeWebAppModalState,
closeWebAppModal,
} = getActions(); } = getActions();
const [mainButton, setMainButton] = useState<WebAppButton | undefined>(); const [mainButton, setMainButton] = useState<WebAppButton | undefined>();
const [secondaryButton, setSecondaryButton] = useState<WebAppButton | undefined>(); const [secondaryButton, setSecondaryButton] = useState<WebAppButton | undefined>();
@ -127,9 +137,35 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
unlockPopupsAt, handlePopupOpened, handlePopupClosed, unlockPopupsAt, handlePopupOpened, handlePopupClosed,
} = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const headerButtonRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const headerButtonCaptionRef = useRef<HTMLDivElement>(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<HTMLElement | null>(null);
useEffect(() => {
fullscreenElementRef.current = document.querySelector('#portals') as HTMLElement;
}, []);
const [, setFullscreen, exitFullscreen] = useFullscreen(fullscreenElementRef, exitFullScreenCallback);
const activeWebApp = modal?.activeWebApp; const activeWebApp = modal?.activeWebApp;
const activeWebAppName = activeWebApp?.appName;
const { const {
url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor, url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor, isBackButtonVisible,
} = webApp || {}; } = webApp || {};
const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen); const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen);
const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen); const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen);
@ -158,8 +194,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
const isSimple = Boolean(buttonText); const isSimple = Boolean(buttonText);
const { const {
reloadFrame, sendEvent, sendViewport, sendTheme, reloadFrame, sendEvent, sendFullScreenChanged, sendViewport, sendSafeArea, sendTheme,
} = useWebAppFrame(frameRef, isOpen, isSimple, handleEvent, webApp, markLoaded); } = useWebAppFrame(frameRef, isOpen, isFullscreen, isSimple, handleEvent, webApp, markLoaded);
useEffect(() => { useEffect(() => {
if (isActive) registerSendEventCallback(sendEvent); if (isActive) registerSendEventCallback(sendEvent);
@ -248,6 +284,32 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}, ANIMATION_WAIT); }, ANIMATION_WAIT);
}, [theme]); }, [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]) => { useSyncEffect(([prevIsPaymentModalOpen]) => {
if (isPaymentModalOpen === prevIsPaymentModalOpen) return; if (isPaymentModalOpen === prevIsPaymentModalOpen) return;
if (webApp?.slug && !isPaymentModalOpen && paymentStatus) { if (webApp?.slug && !isPaymentModalOpen && paymentStatus) {
@ -377,6 +439,24 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
function handleEvent(event: WebAppInboundEvent) { function handleEvent(event: WebAppInboundEvent) {
const { eventType, eventData } = event; 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') { if (eventType === 'web_app_open_tg_link') {
const linkUrl = TME_LINK_PREFIX + eventData.path_full; const linkUrl = TME_LINK_PREFIX + eventData.path_full;
openTelegramLink({ url: linkUrl, shouldIgnoreCache: eventData.force_request }); openTelegramLink({ url: linkUrl, shouldIgnoreCache: eventData.force_request });
@ -514,14 +594,19 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
const [shouldShowMainButton, setShouldShowMainButton] = useState(false); const [shouldShowMainButton, setShouldShowMainButton] = useState(false);
const [shouldShowSecondaryButton, setShouldShowSecondaryButton] = useState(false); const [shouldShowSecondaryButton, setShouldShowSecondaryButton] = useState(false);
const [shouldShowAppNameInFullscreen, setShouldShowAppNameInFullscreen] = useState(false);
const [backButtonTextWidth, setBackButtonTextWidth] = useState(0);
// Notify view that height changed // Notify view that height changed
useSyncEffect(() => { useSyncEffect(() => {
setTimeout(() => { setTimeout(() => {
sendViewport(); sendViewport();
sendSafeArea();
}, ANIMATION_WAIT); }, ANIMATION_WAIT);
}, [shouldShowSecondaryButton, shouldHideSecondaryButton, }, [shouldShowSecondaryButton, shouldHideSecondaryButton,
shouldShowMainButton, shouldShowMainButton, shouldShowMainButton, shouldShowMainButton,
secondaryButton?.position, sendViewport]); secondaryButton?.position, sendViewport, sendSafeArea]);
const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom'; const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom';
const isHorizontalLayout = !isVerticalLayout; const isHorizontalLayout = !isVerticalLayout;
@ -536,6 +621,35 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
const mainButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>(); const mainButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonChangeTimeout = useRef<ReturnType<typeof setTimeout>>(); const secondaryButtonChangeTimeout = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>(); const secondaryButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>();
const appNameDisplayTimeout = useRef<ReturnType<typeof setTimeout>>();
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(() => { useEffect(() => {
if (mainButtonChangeTimeout.current) clearTimeout(mainButtonChangeTimeout.current); if (mainButtonChangeTimeout.current) clearTimeout(mainButtonChangeTimeout.current);
@ -590,30 +704,140 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
const frameWidth = frameSize?.width || 0; const frameWidth = frameSize?.width || 0;
let frameHeight = frameSize?.height || 0; let frameHeight = frameSize?.height || 0;
if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; } if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; }
const frameStyle = buildStyle( const frameStyle = frameSize ? buildStyle(
`left: ${0}px;`, `left: ${0}px;`,
`top: ${0}px;`, `top: ${0}px;`,
`width: ${frameWidth}px;`, `width: ${frameWidth}px;`,
`height: ${frameHeight}px;`, `height: ${frameHeight}px;`,
isDragging ? 'pointer-events: none;' : '', 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 (
<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,
)}
>
<Icon
name="down"
className={buildClassName(
styles.icon,
styles.collapseIcon,
)}
onClick={handleCollapseClick}
/>
</div>
<div
className={buildClassName(
styles.headerButton,
styles.right,
)}
>
<Icon
name="more"
className={buildClassName(
styles.icon,
styles.moreIcon,
)}
onClick={handleShowContextMenu}
/>
</div>
</div>
</div>
);
}
return ( return (
<div <div
ref={containerRef}
className={buildClassName( className={buildClassName(
styles.root, styles.root,
!isActive && styles.hidden, !isActive && styles.hidden,
isMultiTabSupported && styles.multiTab, isMultiTabSupported && styles.multiTab,
)} )}
> >
{isMaximizedState && <Spinner className={buildClassName(styles.loadingSpinner, isLoaded && styles.hide)} />} {isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()}
{!isMinimizedState && <Spinner className={buildClassName(styles.loadingSpinner, isLoaded && styles.hide)} />}
<iframe <iframe
className={buildClassName( className={buildClassName(
styles.frame, styles.frame,
shouldDecreaseWebFrameSize && styles.withButton, shouldDecreaseWebFrameSize && styles.withButton,
!isLoaded && styles.hide, !isLoaded && styles.hide,
)} )}
style={frameSize ? frameStyle : undefined} style={frameStyle}
src={url} src={url}
title={`${bot?.firstName} Web App`} title={`${bot?.firstName} Web App`}
sandbox={SANDBOX_ATTRIBUTES} sandbox={SANDBOX_ATTRIBUTES}
@ -621,7 +845,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
allowFullScreen allowFullScreen
ref={frameRef} ref={frameRef}
/> />
{isMaximizedState && ( {!isMinimizedState && (
<div <div
style={`background-color: ${bottomBarColor};`} style={`background-color: ${bottomBarColor};`}
className={buildClassName( className={buildClassName(
@ -739,7 +963,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => { (global, { modal }): StateProps => {
const { botId: activeBotId } = modal?.activeWebApp || {}; const { botId: activeBotId } = modal?.activeWebApp || {};
const isMaximizedState = modal?.modalState === 'maximized'; const modalState = modal?.modalState;
const attachBot = activeBotId ? global.attachMenu.bots[activeBotId] : undefined; const attachBot = activeBotId ? global.attachMenu.bots[activeBotId] : undefined;
const bot = activeBotId ? selectUser(global, activeBotId) : undefined; const bot = activeBotId ? selectUser(global, activeBotId) : undefined;
@ -757,7 +981,7 @@ export default memo(withGlobal<OwnProps>(
theme, theme,
isPaymentModalOpen: isPaymentModalOpen || Boolean(starsInputInvoice), isPaymentModalOpen: isPaymentModalOpen || Boolean(starsInputInvoice),
paymentStatus, paymentStatus,
isMaximizedState, modalState,
}; };
}, },
)(WebAppModalTabContent)); )(WebAppModalTabContent));

View File

@ -7,6 +7,7 @@ import type { WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/
import { getWebAppKey } from '../../../../global/helpers'; import { getWebAppKey } from '../../../../global/helpers';
import { extractCurrentThemeParams } from '../../../../util/themeStyle'; import { extractCurrentThemeParams } from '../../../../util/themeStyle';
import { REM } from '../../../common/helpers/mediaDimensions';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
import useWindowSize from '../../../../hooks/window/useWindowSize'; import useWindowSize from '../../../../hooks/window/useWindowSize';
@ -32,10 +33,12 @@ const SCROLLBAR_STYLE = `* {
}`; }`;
const RELOAD_TIMEOUT = 500; const RELOAD_TIMEOUT = 500;
const SAFE_AREA_HEIGHT = 3.675 * REM;
const useWebAppFrame = ( const useWebAppFrame = (
ref: React.RefObject<HTMLIFrameElement>, ref: React.RefObject<HTMLIFrameElement>,
isOpen: boolean, isOpen: boolean,
isFullscreen: boolean,
isSimpleView: boolean, isSimpleView: boolean,
onEvent: (event: WebAppInboundEvent) => void, onEvent: (event: WebAppInboundEvent) => void,
webApp?: WebApp, webApp?: WebApp,
@ -75,6 +78,15 @@ const useWebAppFrame = (
ref.current.contentWindow.postMessage(JSON.stringify(event), '*'); ref.current.contentWindow.postMessage(JSON.stringify(event), '*');
}, [ref]); }, [ref]);
const sendFullScreenChanged = useCallback((value: boolean) => {
sendEvent({
eventType: 'fullscreen_changed',
eventData: {
is_fullscreen: value,
},
});
}, [sendEvent]);
const forceReloadFrame = useLastCallback((url: string) => { const forceReloadFrame = useLastCallback((url: string) => {
if (!ref.current) return; if (!ref.current) return;
const frame = ref.current; const frame = ref.current;
@ -114,6 +126,33 @@ const useWebAppFrame = (
}); });
}, [sendEvent, ref]); }, [sendEvent, ref]);
const sendSafeArea = useCallback(() => {
if (!ref.current) {
return;
}
const { height } = ref.current.getBoundingClientRect();
const safeAreaHeight = isFullscreen ? SAFE_AREA_HEIGHT : 0;
sendEvent({
eventType: 'safe_area_changed',
eventData: {
left: 0,
right: 0,
top: 0,
bottom: height - safeAreaHeight,
},
});
sendEvent({
eventType: 'content_safe_area_changed',
eventData: {
left: 0,
right: 0,
top: safeAreaHeight,
bottom: 0,
},
});
}, [sendEvent, isFullscreen, ref]);
const sendTheme = useCallback(() => { const sendTheme = useCallback(() => {
sendEvent({ sendEvent({
eventType: 'theme_changed', eventType: 'theme_changed',
@ -160,6 +199,14 @@ const useWebAppFrame = (
sendViewport(windowSize.isResizing); 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') { if (eventType === 'web_app_request_theme') {
sendTheme(); sendTheme();
} }
@ -272,7 +319,8 @@ const useWebAppFrame = (
} }
}, [ }, [
isSimpleView, sendEvent, onEvent, sendCustomStyle, webApp, isSimpleView, sendEvent, onEvent, sendCustomStyle, webApp,
sendTheme, sendViewport, onLoad, windowSize.isResizing, ref, sendTheme, sendViewport, sendSafeArea, onLoad, windowSize.isResizing,
ref,
]); ]);
useEffect(() => { useEffect(() => {
@ -281,7 +329,8 @@ const useWebAppFrame = (
&& lastFrameSizeRef.current.height === height && !lastFrameSizeRef.current.isResizing) return; && lastFrameSizeRef.current.height === height && !lastFrameSizeRef.current.isResizing) return;
lastFrameSizeRef.current = { width, height, isResizing }; lastFrameSizeRef.current = { width, height, isResizing };
sendViewport(isResizing); sendViewport(isResizing);
}, [sendViewport, windowSize]); sendSafeArea();
}, [sendViewport, sendSafeArea, windowSize]);
useEffect(() => { useEffect(() => {
if (!webApp?.plannedEvents?.length) return; if (!webApp?.plannedEvents?.length) return;
@ -306,14 +355,15 @@ const useWebAppFrame = (
useEffect(() => { useEffect(() => {
if (isOpen && ref.current?.contentWindow) { if (isOpen && ref.current?.contentWindow) {
sendViewport(); sendViewport();
sendSafeArea();
ignoreEventsRef.current = false; ignoreEventsRef.current = false;
} else { } else {
lastFrameSizeRef.current = undefined; lastFrameSizeRef.current = undefined;
} }
}, [isOpen, sendViewport, ref]); }, [isOpen, isFullscreen, sendViewport, sendSafeArea, ref]);
return { return {
sendEvent, reloadFrame, sendViewport, sendTheme, sendEvent, sendFullScreenChanged, reloadFrame, sendViewport, sendSafeArea, sendTheme,
}; };
}; };

View File

@ -54,6 +54,8 @@ import {
} from '../../selectors'; } from '../../selectors';
import { fetchChatByUsername } from './chats'; import { fetchChatByUsername } from './chats';
import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout';
const GAMEE_URL = 'https://prizes.gamee.com/'; const GAMEE_URL = 'https://prizes.gamee.com/';
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runDebouncedForSearch = debounce((cb) => cb(), 500, false); const runDebouncedForSearch = debounce((cb) => cb(), 500, false);
@ -533,6 +535,7 @@ addActionHandler('requestSimpleWebView', async (global, actions, payload): Promi
global = getGlobal(); global = getGlobal();
const newActiveApp: WebApp = { const newActiveApp: WebApp = {
requestUrl: url, requestUrl: url,
appName: bot.firstName,
url: webViewUrl, url: webViewUrl,
botId, botId,
buttonText, buttonText,
@ -543,7 +546,7 @@ addActionHandler('requestSimpleWebView', async (global, actions, payload): Promi
addActionHandler('requestWebView', async (global, actions, payload): Promise<void> => { addActionHandler('requestWebView', async (global, actions, payload): Promise<void> => {
const { const {
url, botId, peerId, theme, isSilent, buttonText, isFromBotMenu, startParam, url, botId, peerId, theme, isSilent, buttonText, isFromBotMenu, startParam, isFullscreen,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -586,17 +589,19 @@ addActionHandler('requestWebView', async (global, actions, payload): Promise<voi
isFromBotMenu, isFromBotMenu,
startParam, startParam,
sendAs, sendAs,
isFullscreen,
}); });
if (!result) { if (!result) {
return; return;
} }
const { url: webViewUrl, queryId } = result; const { url: webViewUrl, queryId, isFullScreen } = result;
global = getGlobal(); global = getGlobal();
const newActiveApp: WebApp = { const newActiveApp: WebApp = {
requestUrl: url, requestUrl: url,
url: webViewUrl, url: webViewUrl,
appName: bot.firstName,
botId, botId,
peerId, peerId,
queryId, queryId,
@ -605,11 +610,15 @@ addActionHandler('requestWebView', async (global, actions, payload): Promise<voi
}; };
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId); global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global); setGlobal(global);
if (isFullScreen && getIsWebAppsFullscreenSupported()) {
actions.changeWebAppModalState({ state: 'fullScreen', tabId });
}
}); });
addActionHandler('requestMainWebView', async (global, actions, payload): Promise<void> => { addActionHandler('requestMainWebView', async (global, actions, payload): Promise<void> => {
const { const {
botId, peerId, theme, startParam, shouldMarkBotTrusted, botId, peerId, theme, startParam, mode, shouldMarkBotTrusted,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -644,16 +653,18 @@ addActionHandler('requestMainWebView', async (global, actions, payload): Promise
peer, peer,
theme, theme,
startParam, startParam,
mode,
}); });
if (!result) { if (!result) {
return; return;
} }
const { url: webViewUrl, queryId } = result; const { url: webViewUrl, queryId, isFullscreen } = result;
global = getGlobal(); global = getGlobal();
const newActiveApp: WebApp = { const newActiveApp: WebApp = {
url: webViewUrl, url: webViewUrl,
appName: bot.firstName,
botId, botId,
peerId, peerId,
queryId, queryId,
@ -661,6 +672,10 @@ addActionHandler('requestMainWebView', async (global, actions, payload): Promise
}; };
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId); global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global); setGlobal(global);
if (isFullscreen && getIsWebAppsFullscreenSupported()) {
actions.changeWebAppModalState({ state: 'fullScreen', tabId });
}
}); });
addActionHandler('loadPreviewMedias', async (global, actions, payload): Promise<void> => { addActionHandler('loadPreviewMedias', async (global, actions, payload): Promise<void> => {
@ -722,7 +737,7 @@ addActionHandler('closeWebAppsCloseConfirmationModal', (global, actions, payload
addActionHandler('requestAppWebView', async (global, actions, payload): Promise<void> => { addActionHandler('requestAppWebView', async (global, actions, payload): Promise<void> => {
const { const {
botId, appName, startApp, theme, isWriteAllowed, isFromConfirm, shouldSkipBotTrustRequest, botId, appName, startApp, mode, theme, isWriteAllowed, isFromConfirm, shouldSkipBotTrustRequest,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -794,10 +809,11 @@ addActionHandler('requestAppWebView', async (global, actions, payload): Promise<
const peer = selectCurrentChat(global, tabId); const peer = selectCurrentChat(global, tabId);
const url = await callApi('requestAppWebView', { const { url, isFullscreen } = await callApi('requestAppWebView', {
peer: peer || bot, peer: peer || bot,
app: botApp, app: botApp,
startParam: startApp, startParam: startApp,
mode,
isWriteAllowed, isWriteAllowed,
theme, theme,
}); });
@ -811,14 +827,17 @@ addActionHandler('requestAppWebView', async (global, actions, payload): Promise<
const newActiveApp: WebApp = { const newActiveApp: WebApp = {
url, url,
appName: appName && bot.firstName,
peerId, peerId,
botId, botId,
appName,
buttonText: '', buttonText: '',
}; };
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId); global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global); setGlobal(global);
if (isFullscreen && getIsWebAppsFullscreenSupported()) {
actions.changeWebAppModalState({ state: 'fullScreen', tabId });
}
}); });
addActionHandler('prolongWebView', async (global, actions, payload): Promise<void> => { addActionHandler('prolongWebView', async (global, actions, payload): Promise<void> => {

View File

@ -1438,6 +1438,7 @@ addActionHandler('openTelegramLink', async (global, actions, payload): Promise<v
startAttach: params.startattach, startAttach: params.startattach,
attach: params.attach, attach: params.attach,
startApp: params.startapp, startApp: params.startapp,
mode: params.mode,
originalParts: [part1, part2, part3], originalParts: [part1, part2, part3],
tabId, tabId,
}); });
@ -1491,7 +1492,7 @@ addActionHandler('acceptChatInvite', async (global, actions, payload): Promise<v
addActionHandler('openChatByUsername', async (global, actions, payload): Promise<void> => { addActionHandler('openChatByUsername', async (global, actions, payload): Promise<void> => {
const { const {
username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, mode,
text, onChatChanged, choose, text, onChatChanged, choose,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -1530,6 +1531,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
peerId: chat.id, peerId: chat.id,
theme, theme,
tabId, tabId,
mode,
}); });
return; return;
} }
@ -1579,6 +1581,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
botId: chatByUsername.id, botId: chatByUsername.id,
tabId, tabId,
startApp, startApp,
mode,
theme, theme,
}); });
return; return;

View File

@ -110,11 +110,9 @@ addActionHandler('closeWebAppModal', (global, actions, payload): ActionReturnTyp
}); });
addActionHandler('changeWebAppModalState', (global, actions, payload): ActionReturnType => { addActionHandler('changeWebAppModalState', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {}; const { state, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
const newModalState = tabState.webApps.modalState === 'maximized' ? 'minimized' : 'maximized'; return replaceWebAppModalState(global, state, tabId);
return replaceWebAppModalState(global, newModalState, tabId);
}); });
addActionHandler('setWebAppPaymentSlug', (global, actions, payload): ActionReturnType => { addActionHandler('setWebAppPaymentSlug', (global, actions, payload): ActionReturnType => {

View File

@ -149,7 +149,7 @@ export type MessageListType =
export type ChatListType = 'active' | 'archived' | 'saved'; export type ChatListType = 'active' | 'archived' | 'saved';
export type WebAppModalStateType = 'maximized' | 'minimized'; export type WebAppModalStateType = 'fullScreen' | 'maximized' | 'minimized';
export interface MessageList { export interface MessageList {
chatId: string; chatId: string;
@ -1844,6 +1844,7 @@ export interface ActionPayloads {
startAttach?: string; startAttach?: string;
attach?: string; attach?: string;
startApp?: string; startApp?: string;
mode?: string;
choose?: ApiChatType[]; choose?: ApiChatType[];
text?: string; text?: string;
originalParts?: (string | undefined)[]; originalParts?: (string | undefined)[];
@ -3121,6 +3122,7 @@ export interface ActionPayloads {
buttonText: string; buttonText: string;
isFromBotMenu?: boolean; isFromBotMenu?: boolean;
startParam?: string; startParam?: string;
isFullscreen?: boolean;
} & WithTabId; } & WithTabId;
updateWebApp: { updateWebApp: {
key: string; key: string;
@ -3131,6 +3133,7 @@ export interface ActionPayloads {
peerId: string; peerId: string;
theme?: ApiThemeParameters; theme?: ApiThemeParameters;
startParam?: string; startParam?: string;
mode?: string;
shouldMarkBotTrusted?: boolean; shouldMarkBotTrusted?: boolean;
} & WithTabId; } & WithTabId;
prolongWebView: { prolongWebView: {
@ -3155,6 +3158,7 @@ export interface ActionPayloads {
appName: string; appName: string;
theme?: ApiThemeParameters; theme?: ApiThemeParameters;
startApp?: string; startApp?: string;
mode?: string;
isWriteAllowed?: boolean; isWriteAllowed?: boolean;
isFromConfirm?: boolean; isFromConfirm?: boolean;
shouldSkipBotTrustRequest?: boolean; shouldSkipBotTrustRequest?: boolean;
@ -3279,7 +3283,9 @@ export interface ActionPayloads {
closeWebAppModal: ({ closeWebAppModal: ({
shouldSkipConfirmation?: boolean; shouldSkipConfirmation?: boolean;
} & WithTabId) | undefined; } & WithTabId) | undefined;
changeWebAppModalState: WithTabId | undefined; changeWebAppModalState: {
state: WebAppModalStateType;
} & WithTabId;
// Misc // Misc
refreshLangPackFromCache: { refreshLangPackFromCache: {

View File

@ -29,6 +29,10 @@ export function getIsTablet() {
return isTablet; return isTablet;
} }
export function getIsWebAppsFullscreenSupported() {
return !getIsMobile();
}
function handleMediaQueryChange() { function handleMediaQueryChange() {
isMobile = mediaQueryCache.get('mobile')?.matches || false; isMobile = mediaQueryCache.get('mobile')?.matches || false;
isTablet = !isMobile && (mediaQueryCache.get('tablet')?.matches || false); isTablet = !isMobile && (mediaQueryCache.get('tablet')?.matches || false);

View File

@ -1,5 +1,7 @@
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { useEffect, useSignal, useState } from '../lib/teact/teact'; import {
useEffect, useSignal, useState,
} from '../lib/teact/teact';
import buildStyle from '../util/buildStyle'; import buildStyle from '../util/buildStyle';
import { captureEvents } from '../util/captureEvents'; import { captureEvents } from '../util/captureEvents';
@ -17,12 +19,14 @@ export interface Point {
} }
let resizeTimeout: number | undefined; let resizeTimeout: number | undefined;
const FULLSCREEN_POSITION = { x: 0, y: 0 };
export default function useDraggable( export default function useDraggable(
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
dragHandleElementRef: RefObject<HTMLElement>, dragHandleElementRef: RefObject<HTMLElement>,
isEnabled: boolean = true, isDragEnabled: boolean = true,
originalSize: Size, originalSize: Size,
isFullscreen: boolean = false,
) { ) {
const [elementCurrentPosition, setElementCurrentPosition] = useState<Point | undefined>(undefined); const [elementCurrentPosition, setElementCurrentPosition] = useState<Point | undefined>(undefined);
const [elementCurrentSize, setElementCurrentSize] = useState<Size | undefined>(undefined); const [elementCurrentSize, setElementCurrentSize] = useState<Size | undefined>(undefined);
@ -48,6 +52,14 @@ export default function useDraggable(
}; };
} }
const updateCurrentPosition = useLastCallback((position: Point) => {
if (!isFullscreen) setElementCurrentPosition({ x: position.x, y: position.y });
});
const getActualPosition = useLastCallback(() => {
return isFullscreen ? FULLSCREEN_POSITION : elementCurrentPosition;
});
const getCenteredPosition = useLastCallback(() => { const getCenteredPosition = useLastCallback(() => {
if (!elementCurrentSize) return undefined; if (!elementCurrentSize) return undefined;
const { width, height } = elementCurrentSize; const { width, height } = elementCurrentSize;
@ -71,7 +83,7 @@ export default function useDraggable(
const centeredPosition = getCenteredPosition(); const centeredPosition = getCenteredPosition();
if (!centeredPosition) return; if (!centeredPosition) return;
setElementCurrentPosition({ x: centeredPosition.x, y: centeredPosition.y }); updateCurrentPosition(centeredPosition);
setIsInitiated(); setIsInitiated();
} }
}, [elementCurrentSize, isInitiated, element]); }, [elementCurrentSize, isInitiated, element]);
@ -95,10 +107,10 @@ export default function useDraggable(
}); });
useEffect(() => { useEffect(() => {
if (!isEnabled) { if (!isDragEnabled) {
stopDragging(); stopDragging();
} }
}, [isEnabled]); }, [isDragEnabled]);
const ensurePositionInVisibleArea = (x: number, y: number) => { const ensurePositionInVisibleArea = (x: number, y: number) => {
const visibleArea = getVisibleArea(); const visibleArea = getVisibleArea();
@ -121,10 +133,11 @@ export default function useDraggable(
}; };
const adjustPositionWithinBounds = useLastCallback(() => { const adjustPositionWithinBounds = useLastCallback(() => {
if (isFullscreen) return;
const position = !wasElementShown ? getCenteredPosition() : elementCurrentPosition; const position = !wasElementShown ? getCenteredPosition() : elementCurrentPosition;
if (!elementCurrentSize || !position) return; if (!elementCurrentSize || !position) return;
const newPosition = ensurePositionInVisibleArea(position.x, position.y); const newPosition = ensurePositionInVisibleArea(position.x, position.y);
setElementCurrentPosition(newPosition); updateCurrentPosition(newPosition);
}); });
const ensureSizeInVisibleArea = useLastCallback((sizeForCheck: Size) => { const ensureSizeInVisibleArea = useLastCallback((sizeForCheck: Size) => {
@ -192,7 +205,7 @@ export default function useDraggable(
useEffect(() => { useEffect(() => {
let cleanup: NoneToVoidFunction | undefined; let cleanup: NoneToVoidFunction | undefined;
if (dragHandleElement && isEnabled) { if (dragHandleElement && isDragEnabled) {
cleanup = captureEvents(dragHandleElement, { cleanup = captureEvents(dragHandleElement, {
onCapture: handleStartDrag, onCapture: handleStartDrag,
onDrag: handleDrag, onDrag: handleDrag,
@ -202,11 +215,13 @@ export default function useDraggable(
}); });
} }
return cleanup; return cleanup;
}, [handleDrag, handleStartDrag, isEnabled, dragHandleElement]); }, [handleDrag, handleStartDrag, isDragEnabled, dragHandleElement]);
const cursorStyle = isDragging ? 'cursor: grabbing !important; ' : ''; const cursorStyle = isDragging ? 'cursor: grabbing !important; ' : '';
if (!isInitiated || !elementCurrentSize || !elementCurrentPosition) { const actualPosition = getActualPosition();
if (!isInitiated || !elementCurrentSize || !actualPosition) {
return { return {
isDragging: false, isDragging: false,
style: cursorStyle, style: cursorStyle,
@ -214,10 +229,10 @@ export default function useDraggable(
} }
const style = buildStyle( const style = buildStyle(
`left: ${elementCurrentPosition.x}px;`, `left: ${actualPosition.x}px;`,
`top: ${elementCurrentPosition.y}px;`, `top: ${actualPosition.y}px;`,
`width: ${elementCurrentSize.width}px;`, !isFullscreen && `max-width: ${elementCurrentSize.width}px;`,
`height: ${elementCurrentSize.height}px;`, !isFullscreen && `max-height: ${elementCurrentSize.height}px;`,
'position: fixed;', 'position: fixed;',
(isDragging || isWindowsResizing) && 'transition: none !important;', (isDragging || isWindowsResizing) && 'transition: none !important;',
cursorStyle, cursorStyle,

View File

@ -4,8 +4,9 @@ import { ElectronEvent } from '../../types/electron';
import { IS_IOS } from '../../util/windowEnvironment'; import { IS_IOS } from '../../util/windowEnvironment';
type ElementType = HTMLElement;
type RefType = { type RefType = {
current: HTMLVideoElement | null; current: ElementType | null;
}; };
type ReturnType = [boolean, () => void, () => void] | [false]; type ReturnType = [boolean, () => void, () => void] | [false];
@ -13,11 +14,12 @@ type CallbackType = (isPlayed: boolean) => void;
const prop = getBrowserFullscreenElementProp(); const prop = getBrowserFullscreenElementProp();
export default function useFullscreen(elRef: RefType, setIsPlayed: CallbackType): ReturnType { export default function useFullscreen(elRef: RefType, exitCallback?: CallbackType,
enterCallback?: CallbackType): ReturnType {
const [isFullscreen, setIsFullscreen] = useState(Boolean(prop && document[prop])); const [isFullscreen, setIsFullscreen] = useState(Boolean(prop && document[prop]));
const setFullscreen = () => { const setFullscreen = () => {
if (!elRef.current || !(prop || IS_IOS)) { if (!elRef.current || !(prop || IS_IOS) || isFullscreen) {
return; return;
} }
safeRequestFullscreen(elRef.current); safeRequestFullscreen(elRef.current);
@ -33,35 +35,45 @@ export default function useFullscreen(elRef: RefType, setIsPlayed: CallbackType)
}; };
useLayoutEffect(() => { useLayoutEffect(() => {
const video = elRef.current; const element = elRef.current;
const listener = () => { const listener = () => {
const isEnabled = Boolean(prop && document[prop]); const isEnabled = Boolean(prop && document[prop]);
setIsFullscreen(isEnabled); setIsFullscreen(isEnabled);
if (isEnabled) {
enterCallback?.(false);
} else {
exitCallback?.(false);
}
// In Firefox fullscreen video controls are not visible by default, so we force them manually // In Firefox fullscreen video controls are not visible by default, so we force them manually
video!.controls = isEnabled; if (element instanceof HTMLVideoElement) element.controls = isEnabled;
}; };
const listenerEnter = () => { setIsFullscreen(true); };
const listenerEnter = () => {
setIsFullscreen(true);
if (enterCallback) enterCallback(true);
};
const listenerExit = () => { const listenerExit = () => {
setIsFullscreen(false); setIsFullscreen(false);
setIsPlayed(false); if (exitCallback) exitCallback(false);
}; };
document.addEventListener('fullscreenchange', listener, false); document.addEventListener('fullscreenchange', listener, false);
document.addEventListener('webkitfullscreenchange', listener, false); document.addEventListener('webkitfullscreenchange', listener, false);
document.addEventListener('mozfullscreenchange', listener, false); document.addEventListener('mozfullscreenchange', listener, false);
if (video) { if (element) {
video.addEventListener('webkitbeginfullscreen', listenerEnter, false); element.addEventListener('webkitbeginfullscreen', listenerEnter, false);
video.addEventListener('webkitendfullscreen', listenerExit, false); element.addEventListener('webkitendfullscreen', listenerExit, false);
} }
return () => { return () => {
document.removeEventListener('fullscreenchange', listener, false); document.removeEventListener('fullscreenchange', listener, false);
document.removeEventListener('webkitfullscreenchange', listener, false); document.removeEventListener('webkitfullscreenchange', listener, false);
document.removeEventListener('mozfullscreenchange', listener, false); document.removeEventListener('mozfullscreenchange', listener, false);
if (video) { if (element) {
video.removeEventListener('webkitbeginfullscreen', listenerEnter, false); element.removeEventListener('webkitbeginfullscreen', listenerEnter, false);
video.removeEventListener('webkitendfullscreen', listenerExit, false); element.removeEventListener('webkitendfullscreen', listenerExit, false);
} }
}; };
// eslint-disable-next-line // eslint-disable-next-line
@ -117,15 +129,15 @@ export function checkIfFullscreen() {
return Boolean(fullscreenProp && document[fullscreenProp]); return Boolean(fullscreenProp && document[fullscreenProp]);
} }
export function safeRequestFullscreen(video: HTMLVideoElement) { export function safeRequestFullscreen(element: ElementType) {
if (video.requestFullscreen) { if (element.requestFullscreen) {
video.requestFullscreen(); element.requestFullscreen();
} else if (video.webkitRequestFullscreen) { } else if (element.webkitRequestFullscreen) {
video.webkitRequestFullscreen(); element.webkitRequestFullscreen();
} else if (video.webkitEnterFullscreen) { } else if (element.webkitEnterFullscreen) {
video.webkitEnterFullscreen(); element.webkitEnterFullscreen();
} else if (video.mozRequestFullScreen) { } else if (element.mozRequestFullScreen) {
video.mozRequestFullScreen(); element.mozRequestFullScreen();
} }
} }

View File

@ -26,6 +26,13 @@ export type WebAppButtonOptions = {
position?: 'left' | 'right' | 'top' | 'bottom'; position?: 'left' | 'right' | 'top' | 'bottom';
}; };
export type SafeArea = {
top: number;
bottom: number;
left: number;
right: number;
};
export type WebAppInboundEvent = export type WebAppInboundEvent =
WebAppEvent<'iframe_ready', { WebAppEvent<'iframe_ready', {
reload_supported?: boolean; reload_supported?: boolean;
@ -102,7 +109,10 @@ export type WebAppInboundEvent =
WebAppEvent<'web_app_request_viewport' | 'web_app_request_theme' | 'web_app_ready' | 'web_app_expand' WebAppEvent<'web_app_request_viewport' | 'web_app_request_theme' | 'web_app_ready' | 'web_app_expand'
| 'web_app_request_phone' | 'web_app_close' | 'web_app_close_scan_qr_popup' | 'web_app_request_phone' | 'web_app_close' | 'web_app_close_scan_qr_popup'
| 'web_app_request_write_access' | 'web_app_request_phone' | 'iframe_will_reload' | 'web_app_request_write_access' | 'web_app_request_phone' | 'iframe_will_reload'
| 'web_app_biometry_get_info' | 'web_app_biometry_open_settings', null>; | 'web_app_biometry_get_info' | 'web_app_biometry_open_settings'
| 'web_app_request_fullscreen' | 'web_app_exit_fullscreen'
| 'web_app_request_safe_area' | 'web_app_request_content_safe_area',
null>;
export type WebAppOutboundEvent = export type WebAppOutboundEvent =
WebAppEvent<'viewport_changed', { WebAppEvent<'viewport_changed', {
@ -111,6 +121,8 @@ export type WebAppOutboundEvent =
is_expanded?: boolean; is_expanded?: boolean;
is_state_stable?: boolean; is_state_stable?: boolean;
}> | }> |
WebAppEvent<'content_safe_area_changed', SafeArea> |
WebAppEvent<'safe_area_changed', SafeArea> |
WebAppEvent<'theme_changed', { WebAppEvent<'theme_changed', {
theme_params: { theme_params: {
bg_color: string; bg_color: string;
@ -133,6 +145,12 @@ export type WebAppOutboundEvent =
WebAppEvent<'popup_closed', { WebAppEvent<'popup_closed', {
button_id?: string; button_id?: string;
}> | }> |
WebAppEvent<'fullscreen_changed', {
is_fullscreen: boolean;
}> |
WebAppEvent<'fullscreen_failed', {
error: 'UNSUPPORTED' | string;
}> |
WebAppEvent<'qr_text_received', { WebAppEvent<'qr_text_received', {
data: string; data: string;
}> | }> |

View File

@ -61,6 +61,7 @@ interface PublicUsernameOrBotLink {
username: string; username: string;
start?: string; start?: string;
startApp?: string; startApp?: string;
mode?: string;
appName?: string; appName?: string;
startAttach?: string; startAttach?: string;
attach?: string; attach?: string;
@ -201,6 +202,7 @@ function parseTgLink(url: URL) {
text: queryParams.text, text: queryParams.text,
appName: queryParams.appname, appName: queryParams.appname,
startApp: queryParams.startapp, startApp: queryParams.startapp,
mode: queryParams.mode,
startAttach: queryParams.startattach, startAttach: queryParams.startattach,
attach: queryParams.attach, attach: queryParams.attach,
choose: queryParams.choose, choose: queryParams.choose,
@ -293,6 +295,7 @@ function parseHttpLink(url: URL) {
start: queryParams.start, start: queryParams.start,
text: queryParams.text, text: queryParams.text,
startApp: queryParams.startapp, startApp: queryParams.startapp,
mode: queryParams.mode,
appName: undefined, appName: undefined,
startAttach: queryParams.startattach, startAttach: queryParams.startattach,
attach: queryParams.attach, attach: queryParams.attach,
@ -523,6 +526,7 @@ function buildPublicUsernameOrBotLink(
start, start,
text, text,
startApp, startApp,
mode,
startAttach, startAttach,
attach, attach,
appName, appName,
@ -539,6 +543,7 @@ function buildPublicUsernameOrBotLink(
username, username,
start, start,
startApp, startApp,
mode,
appName, appName,
startAttach, startAttach,
attach, attach,

View File

@ -25,6 +25,7 @@ export const processDeepLink = (url: string): boolean => {
startParam: parsedLink.start, startParam: parsedLink.start,
text: parsedLink.text, text: parsedLink.text,
startApp: parsedLink.startApp, startApp: parsedLink.startApp,
mode: parsedLink.mode,
startAttach: parsedLink.startAttach, startAttach: parsedLink.startAttach,
attach: parsedLink.attach, attach: parsedLink.attach,
choose, choose,
@ -81,7 +82,7 @@ export const processDeepLink = (url: string): boolean => {
case 'resolve': { case 'resolve': {
const { const {
domain, phone, post, comment, voicechat, livestream, start, startattach, attach, thread, topic, domain, phone, post, comment, voicechat, livestream, start, startattach, attach, thread, topic,
appname, startapp, story, text, appname, startapp, mode, story, text,
} = params; } = params;
const hasBoost = params.hasOwnProperty('boost'); const hasBoost = params.hasOwnProperty('boost');
@ -92,6 +93,7 @@ export const processDeepLink = (url: string): boolean => {
openChatByUsername({ openChatByUsername({
username: domain, username: domain,
startApp: startapp, startApp: startapp,
mode,
originalParts: [domain, appname], originalParts: [domain, appname],
text, text,
}); });
@ -117,6 +119,7 @@ export const processDeepLink = (url: string): boolean => {
messageId: post ? Number(post) : undefined, messageId: post ? Number(post) : undefined,
commentId: comment ? Number(comment) : undefined, commentId: comment ? Number(comment) : undefined,
startParam: start, startParam: start,
mode,
startAttach: startattach, startAttach: startattach,
attach, attach,
threadId, threadId,