From faee4152f647765b314fe805725273eccd218d92 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 27 Sep 2024 16:11:23 +0200 Subject: [PATCH] Mini Apps: Multi window in desktop (#4921) --- src/assets/font-icons/collapse-modal.svg | 1 + src/assets/font-icons/expand-modal.svg | 1 + src/assets/localization/fallback.strings | 2 + src/bundles/extra.ts | 1 + src/components/modals/ModalContainer.tsx | 4 +- .../webApp/MinimizedWebAppModal.module.scss | 45 + .../modals/webApp/MinimizedWebAppModal.tsx | 137 +++ .../modals/webApp/WebAppModal.module.scss | 246 ++++- src/components/modals/webApp/WebAppModal.tsx | 928 ++++++++---------- .../webApp/WebAppModalTabContent.module.scss | 84 ++ .../modals/webApp/WebAppModalTabContent.tsx | 648 ++++++++++++ .../modals/webApp/hooks/useWebAppFrame.ts | 23 +- src/components/ui/Modal.tsx | 4 +- src/global/actions/api/bots.ts | 188 +++- src/global/actions/apiUpdaters/misc.ts | 4 +- src/global/actions/ui/bots.ts | 17 + src/global/helpers/bots.ts | 9 + src/global/initialState.ts | 8 + src/global/reducers/bots.ts | 241 ++++- src/global/types.ts | 56 +- src/hooks/useDraggable.ts | 232 +++++ src/styles/_mixins.scss | 8 + src/styles/icons.scss | 392 ++++---- src/styles/icons.woff | Bin 30752 -> 30892 bytes src/styles/icons.woff2 | Bin 25692 -> 25860 bytes src/styles/themes.json | 1 + src/types/icons/font.ts | 2 + src/types/language.d.ts | 3 + 28 files changed, 2505 insertions(+), 780 deletions(-) create mode 100644 src/assets/font-icons/collapse-modal.svg create mode 100644 src/assets/font-icons/expand-modal.svg create mode 100644 src/components/modals/webApp/MinimizedWebAppModal.module.scss create mode 100644 src/components/modals/webApp/MinimizedWebAppModal.tsx create mode 100644 src/components/modals/webApp/WebAppModalTabContent.module.scss create mode 100644 src/components/modals/webApp/WebAppModalTabContent.tsx create mode 100644 src/global/actions/ui/bots.ts create mode 100644 src/hooks/useDraggable.ts diff --git a/src/assets/font-icons/collapse-modal.svg b/src/assets/font-icons/collapse-modal.svg new file mode 100644 index 000000000..78eae08a2 --- /dev/null +++ b/src/assets/font-icons/collapse-modal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/expand-modal.svg b/src/assets/font-icons/expand-modal.svg new file mode 100644 index 000000000..0c5eef915 --- /dev/null +++ b/src/assets/font-icons/expand-modal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 50f666614..642269eb2 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1283,4 +1283,6 @@ "CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}" "CreditsBoxOutAbout" = "Review the {link} for Stars." "GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram." +"MiniAppsMoreTabs_one" = "{botName} & {count} Other"; +"MiniAppsMoreTabs_other" = "{botName} & {count} Others"; "PrizeCredits" = "Your prize is {count} Stars." diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 3e33b44ea..4e47b14f2 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -12,6 +12,7 @@ export { default as UrlAuthModal } from '../components/modals/urlAuth/UrlAuthMod export { default as HistoryCalendar } from '../components/main/HistoryCalendar'; export { default as NewContactModal } from '../components/main/NewContactModal'; export { default as WebAppModal } from '../components/modals/webApp/WebAppModal'; +export { default as MinimizedWebAppModal } from '../components/modals/webApp/MinimizedWebAppModal'; export { default as BotTrustModal } from '../components/main/BotTrustModal'; export { default as AttachBotInstallModal } from '../components/modals/attachBotInstall/AttachBotInstallModal'; export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog'; diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 22a0acfc6..ddaef243b 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -35,7 +35,7 @@ type ModalKey = keyof Pick; @@ -52,7 +52,6 @@ type Entries = { }[keyof T][]; const MODALS: ModalRegistry = { - webApp: WebAppModal, giftCodeModal: GiftCodeModal, boostModal: BoostModal, chatlistModal: ChatlistModal, @@ -61,6 +60,7 @@ const MODALS: ModalRegistry = { inviteViaLinkModal: InviteViaLinkModal, requestedAttachBotInstall: AttachBotInstallModal, reportAdModal: ReportAdModal, + webApps: WebAppModal, collectibleInfoModal: CollectibleInfoModal, mapModal: MapModal, isStarPaymentModalOpen: StarsPaymentModal, diff --git a/src/components/modals/webApp/MinimizedWebAppModal.module.scss b/src/components/modals/webApp/MinimizedWebAppModal.module.scss new file mode 100644 index 000000000..a0f6b25f7 --- /dev/null +++ b/src/components/modals/webApp/MinimizedWebAppModal.module.scss @@ -0,0 +1,45 @@ +.root { + height: 2.5rem; + font-size: 0.875rem; + font-weight: 500; + + border-radius: var(--border-radius-default); + background-color: transparent; + padding: 0; + padding-inline: 0.5rem; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.window-state-button, +.button { + width: 1.75rem !important; + height: 1.75rem !important; +} + +.window-state-button { + margin-left: auto; +} + +.avatars { + margin-left: 0.5rem; +} + +.state-icon, +.icon { + color: var(--color-text-secondary); + opacity: 0.75; +} + +.state-icon { + font-size: 2rem !important; +} + +.title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 0.5rem; + margin-right: 0.5rem; +} diff --git a/src/components/modals/webApp/MinimizedWebAppModal.tsx b/src/components/modals/webApp/MinimizedWebAppModal.tsx new file mode 100644 index 000000000..4953d1103 --- /dev/null +++ b/src/components/modals/webApp/MinimizedWebAppModal.tsx @@ -0,0 +1,137 @@ +import React, { + memo, useMemo, + useRef, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { ApiUser } from '../../../api/types'; +import type { WebApp } from '../../../global/types'; + +import { selectTabState, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { unique } from '../../../util/iteratees'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import AvatarList from '../../common/AvatarList'; +import Icon from '../../common/icons/Icon'; +import Button from '../../ui/Button'; + +import styles from './MinimizedWebAppModal.module.scss'; + +type StateProps = { + activeTabBot?: ApiUser; + isMinimizedState?: boolean; + openedWebApps?: Record; +}; + +const MinimizedWebAppModal = ({ + activeTabBot, isMinimizedState, openedWebApps, +}: StateProps) => { + const { + changeWebAppModalState, + closeWebAppModal, + } = getActions(); + + const oldLang = useOldLang(); + const lang = useLang(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + + const openedWebAppsValues = useMemo(() => { + return openedWebApps && Object.values(openedWebApps); + }, [openedWebApps]); + + const openedTabsCount = openedWebAppsValues?.length; + + const peers = useMemo(() => { + if (!openedTabsCount) return []; + + const global = getGlobal(); + const activeTabBotId = activeTabBot?.id; + const openedApps = unique([activeTabBotId, ...openedWebAppsValues.map((app) => app.botId)]); + const bots = openedApps.map((id) => id && selectUser(global, id)).filter(Boolean).slice(0, 3); + return bots; + }, [openedTabsCount, activeTabBot, openedWebAppsValues]); + + const handleCloseClick = useLastCallback(() => { + closeWebAppModal(); + }); + + const handleExpandClick = useLastCallback(() => { + changeWebAppModalState(); + }); + + if (!isMinimizedState) return undefined; + + function renderTitle() { + const activeTabName = activeTabBot?.firstName; + const title = openedTabsCount && activeTabName && openedTabsCount > 1 + ? `${lang('MiniAppsMoreTabs', + { + botName: activeTabName, + count: openedTabsCount - 1, + })}` + : activeTabName; + + return ( +
+ {title} +
+ ); + } + + return ( +
+ + + {renderTitle()} + +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const tabState = selectTabState(global); + const webApps = tabState.webApps; + + const { botId } = webApps?.activeWebApp || {}; + const { modalState, openedWebApps } = webApps || {}; + const isMinimizedState = modalState === 'minimized'; + const activeTabBot = botId ? selectUser(global, botId) : undefined; + + return { + activeTabBot, + isMinimizedState, + openedWebApps, + }; + }, +)(MinimizedWebAppModal)); diff --git a/src/components/modals/webApp/WebAppModal.module.scss b/src/components/modals/webApp/WebAppModal.module.scss index 9345cad0a..bfd708db3 100644 --- a/src/components/modals/webApp/WebAppModal.module.scss +++ b/src/components/modals/webApp/WebAppModal.module.scss @@ -1,15 +1,21 @@ +@use '../../../styles/mixins'; + .root { --color-transition: 0.25s ease-in-out; + --more-button-opacity: 0; + --modal-shadow: 0 0 1rem rgba(0, 0, 0, 0.15); + --active-tab-background: var(--color-background); + --state-transition: 0.25s cubic-bezier(0.29, 0.81, 0.27, 0.99); :global { .modal-header { color: var(--color-header-text); - border-bottom: 1px solid var(--color-dividers); padding: 0.5rem; - + border-bottom: 1px solid var(--color-dividers); transition: var(--color-transition) background-color, var(--color-transition) color; } .modal-dialog { + pointer-events: auto; height: 75%; justify-content: center; border: none; @@ -18,6 +24,10 @@ overflow: hidden; } + .modal-container { + pointer-events: none; + } + .modal-content { display: flex; flex-direction: column; @@ -45,12 +55,243 @@ } } +.multi-tab { + :global { + .modal-dialog { + 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; + box-shadow: var(--modal-shadow); + } + + .modal-header { + padding: 0; + padding-inline: 0.5rem; + border-bottom: 0; + } + + .modal-content { + background-color: var(--color-background); + border-top-right-radius: var(--border-radius-default); + border-top-left-radius: var(--border-radius-default); + box-shadow: var(--modal-shadow); + max-height: none; + } + + @media (max-width: 600px) { + .modal-dialog { + background-color: var(--color-web-app-browser); + } + } + } + + .close-icon { + opacity: 0.75; + position: absolute; + transform: rotate(-45deg); + + &, + &::before, + &::after { + background-color: var(--color-text-secondary); + } + } +} + +.minimized { + :global { + .modal-dialog { + cursor: grab !important; + width: 300px; + height: 2.5rem; + min-width: 0; + } + } +} + +.tabs { + display: flex; + align-items: center; + height: 100%; + flex-grow: 1; + + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + + scrollbar-width: none; + scrollbar-color: rgba(0, 0, 0, 0); + + padding-left: 0.5rem; + padding-right: 0.5rem; + + @include mixins.gradient-border-horizontal(0.5rem, calc(100% - 0.5rem)); + + &::-webkit-scrollbar { + height: 0; + } + + &::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0); + } +} + +.tab-button-wrapper { + display: flex; + margin-left: -0.5rem; + margin-right: -0.5rem; +} + +.tab-button { + position: relative; + z-index: 1; + + transition: var(--color-transition) background-color, var(--color-transition) color; + background-color: var(--active-tab-background); + color: var(--color-header-text); + + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0.5rem; + padding-left: 1rem; + padding-right: 1rem; + + font-size: 0.875rem; + font-weight: 500; + text-overflow: ellipsis; + + border-top-right-radius: var(--border-radius-default); + border-top-left-radius: var(--border-radius-default); + + box-shadow: var(--modal-shadow); + + &:hover, + &:focus { + --more-button-opacity: 1; + + .tab-right-mask { + opacity: 1; + } + + .tab-close-button { + opacity: 1; + } + } +} + +.tab-right-mask { + @include mixins.gradient-border-left(2rem); + + opacity: 0; + transition: opacity 0.25s ease-in-out; + + position: absolute; + width: 4rem; + height: 100%; + right: 0; + + background-color: var(--active-tab-background); + border-top-right-radius: var(--border-radius-default); +} + +.tab-button-curve-path { + fill: var(--active-tab-background); + transition: var(--color-transition) fill; +} + +.tab-button-left-curve, +.tab-button-right-curve { + position: relative; + z-index: 2; + + transition: var(--color-transition) background-color, var(--color-transition) color; + display: flex; + align-items: center; + justify-content: center; + margin-top: auto; +} + +.tab-button-right-curve { + transform: scaleX(-1); +} + +.avatar-container { + position: relative; + display: flex; + align-items: center; + margin-right: 0.5rem; +} + +.web-app-tab-more-menu { + z-index: 1; + position: absolute; +} + .more-button { opacity: 0.75; color: var(--color-header-text) !important; transition: var(--color-transition) color; } +.tab-more-button { + z-index: 2; + padding: 0 !important; + width: 1.5rem !important; + height: 1.5rem !important; + font-size: 0.75rem; + opacity: var(--more-button-opacity); + color: white !important; + transition: opacity 0.25s ease-in-out; + background-color: rgba(0, 0, 0, 0.45) !important; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.window-state-button, +.header-button { + width: 1.75rem !important; + height: 1.75rem !important; +} + +.tab-close-button { + transition: opacity 0.25s ease-in-out; + opacity: 0; + position: absolute; + right: 0.5rem; + color: var(--color-header-text) !important; + + width: 1.5rem !important; + height: 1.5rem !important; +} + +.tab-close-icon { + opacity: 0.75; + font-size: 1rem !important; +} + +.state-icon, +.icon { + opacity: 0.75; +} + +.state-icon { + font-size: 2rem !important; +} + +.tab-avatar { + cursor: var(--custom-cursor, pointer); + margin-right: 0.5rem; + margin-left: 0.5rem; +} + .close-icon { opacity: 0.75; position: absolute; @@ -93,6 +334,7 @@ .loading-spinner { position: absolute; + pointer-events: none; top: 50%; left: 50%; transform: translate(-50%, -50%); diff --git a/src/components/modals/webApp/WebAppModal.tsx b/src/components/modals/webApp/WebAppModal.tsx index cef7ac191..027a7c4c5 100644 --- a/src/components/modals/webApp/WebAppModal.tsx +++ b/src/components/modals/webApp/WebAppModal.tsx @@ -1,16 +1,18 @@ +import { type MouseEvent as ReactMouseEvent } from 'react'; import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useEffect, useMemo, useRef, useState, + memo, useEffect, + useMemo, useRef, + useSignal, useState, } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import { getActions, getGlobal, withGlobal } from '../../../global'; import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types'; -import type { TabState } from '../../../global/types'; +import type { TabState, WebApp } from '../../../global/types'; import type { ThemeKey } from '../../../types'; -import type { PopupOptions, WebAppInboundEvent } from '../../../types/webapp'; +import type { WebAppOutboundEvent } from '../../../types/webapp'; -import { TME_LINK_PREFIX } from '../../../config'; -import { convertToApiChatType } from '../../../global/helpers'; +import { getWebAppKey } from '../../../global/helpers/bots'; import { selectCurrentChat, selectTabState, selectTheme, selectUser, } from '../../../global/selectors'; @@ -18,41 +20,35 @@ import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle'; import { getColorLuma } from '../../../util/colors'; import { hexToRgb } from '../../../util/switchTheme'; -import { extractCurrentThemeParams, validateHexColor } from '../../../util/themeStyle'; -import { callApi } from '../../../api/gramjs'; -import renderText from '../../common/helpers/renderText'; import useInterval from '../../../hooks/schedulers/useInterval'; import useAppLayout from '../../../hooks/useAppLayout'; -import useFlag from '../../../hooks/useFlag'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; +import useDraggable from '../../../hooks/useDraggable'; +import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; -import usePreviousDeprecated from '../../../hooks/usePreviousDeprecated'; -import useSyncEffect from '../../../hooks/useSyncEffect'; -import usePopupLimit from './hooks/usePopupLimit'; -import useWebAppFrame from './hooks/useWebAppFrame'; +import Avatar from '../../common/Avatar'; import Icon from '../../common/icons/Icon'; import Button from '../../ui/Button'; -import ConfirmDialog from '../../ui/ConfirmDialog'; import DropdownMenu from '../../ui/DropdownMenu'; +import Menu from '../../ui/Menu'; import MenuItem from '../../ui/MenuItem'; import Modal from '../../ui/Modal'; -import Spinner from '../../ui/Spinner'; +import MinimizedWebAppModal from './MinimizedWebAppModal'; +import WebAppModalTabContent from './WebAppModalTabContent'; import styles from './WebAppModal.module.scss'; -type WebAppButton = { - isVisible: boolean; - isActive: boolean; - text: string; - color: string; - textColor: string; - isProgressVisible: boolean; +type WebAppModalTab = { + bot?: ApiUser; + webApp: WebApp; + isOpen: boolean; }; export type OwnProps = { - modal?: TabState['webApp']; + modal?: TabState['webApps']; }; type StateProps = { @@ -64,28 +60,8 @@ type StateProps = { paymentStatus?: TabState['payment']['status']; }; -const NBSP = '\u00A0'; - -const MAIN_BUTTON_ANIMATION_TIME = 250; const PROLONG_INTERVAL = 45000; // 45s -const ANIMATION_WAIT = 400; -const POPUP_SEQUENTIAL_LIMIT = 3; const LUMA_THRESHOLD = 128; -const POPUP_RESET_DELAY = 2000; // 2s -const SANDBOX_ATTRIBUTES = [ - 'allow-scripts', - 'allow-same-origin', - 'allow-popups', - 'allow-forms', - 'allow-modals', - 'allow-storage-access-by-user-activation', -].join(' '); - -const DEFAULT_BUTTON_TEXT: Record = { - ok: 'OK', - cancel: 'Cancel', - close: 'Close', -}; const WebAppModal: FC = ({ modal, @@ -93,77 +69,152 @@ const WebAppModal: FC = ({ bot, attachBot, theme, - isPaymentModalOpen, - paymentStatus, }) => { const { - closeWebApp, - sendWebViewData, + closeActiveWebApp, + closeWebAppModal, prolongWebView, toggleAttachBot, - openTelegramLink, openChat, - setWebAppPaymentSlug, - switchBotInline, - sharePhoneWithBot, + changeWebAppModalState, + openWebAppTab, + updateWebApp, } = getActions(); - const [mainButton, setMainButton] = useState(); - const [isBackButtonVisible, setIsBackButtonVisible] = useState(false); - const [isSettingsButtonVisible, setIsSettingsButtonVisible] = useState(false); - const [backgroundColor, setBackgroundColor] = useState(); - const [headerColor, setHeaderColor] = useState(); + const maximizedStateSize = useMemo(() => { + return { width: 420, height: 730 }; + }, []); + const minimizedStateSize = useMemo(() => { + return { width: 300, height: 40 }; + }, []); + const [getFrameSize, setFrameSize] = useSignal( + { width: maximizedStateSize.width, height: maximizedStateSize.height - minimizedStateSize.height }, + ); - const [shouldConfirmClosing, setShouldConfirmClosing] = useState(false); - const [isCloseModalOpen, openCloseModal, hideCloseModal] = useFlag(false); - const [isRemoveModalOpen, openRemoveModal, hideRemoveModal] = useFlag(false); + function getSize() { + return modal?.modalState === 'maximized' ? maximizedStateSize : minimizedStateSize; + } - const [isLoaded, markLoaded, markUnloaded] = useFlag(false); - - const [popupParameters, setPopupParameters] = useState(); - const [isRequestingPhone, setIsRequestingPhone] = useState(false); - const [isRequesingWriteAccess, setIsRequestingWriteAccess] = useState(false); const { - unlockPopupsAt, handlePopupOpened, handlePopupClosed, - } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); + openedWebApps, activeWebApp, openedOrderedKeys, sessionKeys, + } = modal || {}; + const { + isBackButtonVisible, headerColor, backgroundColor, isSettingsButtonVisible, + } = activeWebApp || {}; + + const tabs = useMemo(() => { + return openedOrderedKeys?.map((key) => { + const webApp = openedWebApps![key]; + return { + bot: getGlobal().users.byId[webApp.botId], + webApp, + isOpen: Boolean(activeWebApp && (key === getWebAppKey(activeWebApp))), + }; + }); + }, [openedOrderedKeys, openedWebApps, activeWebApp]); const { isMobile } = useAppLayout(); + const isOpen = modal?.isModalOpen || false; + const isMaximizedState = modal?.modalState === 'maximized'; + + const supportMultiTabMode = !isMobile; + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const headerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); + + const getTriggerElement = useLastCallback(() => ref.current!); + + const getRootElement = useLastCallback( + () => ref.current!, + ); + + const getMenuElement = useLastCallback( + () => menuRef.current!, + ); + + const { + isContextMenuOpen, + contextMenuAnchor, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref); + + const [isDraggingEnabled, setIsDraggingEnabled] = useState(false); + + const headerElement = headerRef.current; + const containerElement = ref.current; useEffect(() => { - const themeParams = extractCurrentThemeParams(); - setBackgroundColor(themeParams.bg_color); - setHeaderColor(themeParams.bg_color); - }, []); + setIsDraggingEnabled(Boolean(supportMultiTabMode && headerElement && containerElement)); + }, [supportMultiTabMode, headerElement, containerElement]); - // eslint-disable-next-line no-null/no-null - const frameRef = useRef(null); + const { + isDragging, + style: draggableStyle, + size, + } = useDraggable(ref, headerRef, isDraggingEnabled, getSize()); + + const currentSize = size || getSize(); + + const currentWidth = currentSize.width; + const currentHeight = currentSize.height; + useEffect(() => { + if (currentHeight === minimizedStateSize.height && currentWidth === minimizedStateSize.width) return; + if (isMaximizedState) { + const height = currentHeight - minimizedStateSize.height; + setFrameSize({ width: currentWidth, height }); + } + }, [currentWidth, currentHeight, isMaximizedState, minimizedStateSize, setFrameSize]); const lang = useOldLang(); const { - url, buttonText, queryId, replyInfo, - } = modal || {}; - const isOpen = Boolean(url); - const isSimple = Boolean(buttonText); + queryId, + } = activeWebApp || {}; - const { - reloadFrame, sendEvent, sendViewport, sendTheme, - } = useWebAppFrame(frameRef, isOpen, isSimple, handleEvent, markLoaded); - - const shouldShowMainButton = mainButton?.isVisible && mainButton.text.trim().length > 0; + const openTabsCount = openedWebApps ? Object.values(openedWebApps).length : 0; useInterval(() => { - prolongWebView({ - botId: bot!.id, - queryId: queryId!, - peerId: chat!.id, - replyInfo, + if (!openedWebApps) return; + Object.keys(openedWebApps).forEach((key) => { + const webApp = openedWebApps[key]; + if (webApp.queryId) { + prolongWebView({ + botId: webApp.botId, + queryId: webApp.queryId, + peerId: webApp.peerId || chat!.id, + replyInfo: webApp.replyInfo, + }); + } }); }, queryId ? PROLONG_INTERVAL : undefined, true); - const handleMainButtonClick = useLastCallback(() => { - sendEvent({ - eventType: 'main_button_pressed', - }); + // eslint-disable-next-line no-null/no-null + const sendEventCallback = useRef<((event: WebAppOutboundEvent) => void) | null>(null); + // eslint-disable-next-line no-null/no-null + const reloadFrameCallback = useRef<((url: string) => void) | null>(null); + + const registerSendEventCallback = useLastCallback((callback: (event: WebAppOutboundEvent) => void) => { + sendEventCallback.current = callback; + }); + + const sendEvent = useLastCallback((event: WebAppOutboundEvent) => { + if (sendEventCallback.current) { + sendEventCallback.current(event); + } + }); + + const registerReloadFrameCallback = useLastCallback((callback: (url: string) => void) => { + reloadFrameCallback.current = callback; + }); + + const reloadFrame = useLastCallback((url: string) => { + if (reloadFrameCallback.current) { + reloadFrameCallback.current(url); + } }); const handleSettingsButtonClick = useLastCallback(() => { @@ -173,73 +224,29 @@ const WebAppModal: FC = ({ }); const handleRefreshClick = useLastCallback(() => { - reloadFrame(modal!.url); + reloadFrame(activeWebApp!.url); }); - const handleClose = useLastCallback(() => { - if (shouldConfirmClosing) { - openCloseModal(); + const handleModalClose = useLastCallback(() => { + closeWebAppModal(); + }); + + const handleTabClose = useLastCallback(() => { + if (openTabsCount > 1) { + closeActiveWebApp(); } else { - closeWebApp(); + closeWebAppModal(); } }); - const handleAppPopupClose = useLastCallback((buttonId?: string) => { - setPopupParameters(undefined); - handlePopupClosed(); - sendEvent({ - eventType: 'popup_closed', - eventData: { - button_id: buttonId, - }, - }); - }); - - const handleAppPopupModalClose = useLastCallback(() => { - handleAppPopupClose(); - }); - - // Notify view that height changed - useSyncEffect(() => { - setTimeout(() => { - sendViewport(); - }, ANIMATION_WAIT); - }, [mainButton?.isVisible, sendViewport]); - - // Notify view that theme changed - useSyncEffect(() => { - setTimeout(() => { - sendTheme(); - }, ANIMATION_WAIT); - }, [theme, sendTheme]); - - useSyncEffect(([prevIsPaymentModalOpen]) => { - if (isPaymentModalOpen === prevIsPaymentModalOpen) return; - if (modal?.slug && !isPaymentModalOpen && paymentStatus) { - sendEvent({ - eventType: 'invoice_closed', - eventData: { - slug: modal.slug, - status: paymentStatus, - }, - }); - setWebAppPaymentSlug({ - slug: undefined, - }); - } - }, [isPaymentModalOpen, paymentStatus, sendEvent, modal?.slug]); - - const handleRemoveAttachBot = useLastCallback(() => { - toggleAttachBot({ - botId: bot!.id, - isEnabled: false, - }); - closeWebApp(); - }); - const handleToggleClick = useLastCallback(() => { if (attachBot) { - openRemoveModal(); + updateWebApp({ + webApp: { + ...activeWebApp!, + isRemoveModalOpen: true, + }, + }); return; } @@ -255,251 +262,107 @@ const WebAppModal: FC = ({ eventType: 'back_button_pressed', }); } else { - handleClose(); + handleModalClose(); } }); - const handleRejectPhone = useLastCallback(() => { - setIsRequestingPhone(false); - handlePopupClosed(); - sendEvent({ - eventType: 'phone_requested', - eventData: { - status: 'cancelled', - }, - }); + const handleCollapseClick = useLastCallback(() => { + changeWebAppModalState(); }); - const handleAcceptPhone = useLastCallback(() => { - sharePhoneWithBot({ botId: bot!.id }); - setIsRequestingPhone(false); - handlePopupClosed(); - sendEvent({ - eventType: 'phone_requested', - eventData: { - status: 'sent', - }, - }); + const handleTabClick = useLastCallback((tab: WebAppModalTab) => { + openWebAppTab({ webApp: tab.webApp }); }); - const handleRejectWriteAccess = useLastCallback(() => { - sendEvent({ - eventType: 'write_access_requested', - eventData: { - status: 'cancelled', - }, - }); - setIsRequestingWriteAccess(false); - handlePopupClosed(); - }); - - const handleAcceptWriteAccess = useLastCallback(async () => { - const result = await callApi('allowBotSendMessages', { bot: bot! }); - if (!result) { - handleRejectWriteAccess(); - return; - } - - sendEvent({ - eventType: 'write_access_requested', - eventData: { - status: 'allowed', - }, - }); - setIsRequestingWriteAccess(false); - handlePopupClosed(); - }); - - async function handleRequestWriteAccess() { - const canWrite = await callApi('fetchBotCanSendMessage', { - bot: bot!, - }); - - if (canWrite) { - sendEvent({ - eventType: 'write_access_requested', - eventData: { - status: 'allowed', - }, - }); - } - - setIsRequestingWriteAccess(!canWrite); - } - - 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, - }, - }); - } - const openBotChat = useLastCallback(() => { openChat({ id: bot!.id, }); - closeWebApp(); }); - useEffect(() => { - if (!isOpen) { - const themeParams = extractCurrentThemeParams(); - - setShouldConfirmClosing(false); - hideCloseModal(); - hideRemoveModal(); - setPopupParameters(undefined); - setIsRequestingPhone(false); - setIsRequestingWriteAccess(false); - setMainButton(undefined); - setIsBackButtonVisible(false); - setIsSettingsButtonVisible(false); - setBackgroundColor(themeParams.bg_color); - setHeaderColor(themeParams.bg_color); - markUnloaded(); - } - }, [isOpen]); - - function handleEvent(event: WebAppInboundEvent) { - const { eventType, eventData } = event; - if (eventType === 'web_app_open_tg_link' && !isPaymentModalOpen) { - const linkUrl = TME_LINK_PREFIX + eventData.path_full; - openTelegramLink({ url: linkUrl }); - closeWebApp(); - } - - if (eventType === 'web_app_setup_back_button') { - setIsBackButtonVisible(eventData.is_visible); - } - - if (eventType === 'web_app_setup_settings_button') { - setIsSettingsButtonVisible(eventData.is_visible); - } - - if (eventType === 'web_app_set_background_color') { - const themeParams = extractCurrentThemeParams(); - const color = validateHexColor(eventData.color) ? eventData.color : themeParams.bg_color; - setBackgroundColor(color); - } - - if (eventType === 'web_app_set_header_color') { - if (eventData.color_key) { - const themeParams = extractCurrentThemeParams(); - const key = eventData.color_key; - const newColor = themeParams[key]; - const color = validateHexColor(newColor) ? newColor : headerColor; - setHeaderColor(color); - } - - if (eventData.color) { - const color = validateHexColor(eventData.color) ? eventData.color : headerColor; - setHeaderColor(color); - } - } - - if (eventType === 'web_app_data_send') { - closeWebApp(); - sendWebViewData({ - bot: bot!, - buttonText: buttonText!, - data: eventData.data, - }); - } - - if (eventType === 'web_app_setup_main_button') { - const themeParams = extractCurrentThemeParams(); - const color = validateHexColor(eventData.color) ? eventData.color : themeParams.button_color; - const textColor = validateHexColor(eventData.text_color) ? eventData.text_color : themeParams.text_color; - setMainButton({ - isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length), - isActive: eventData.is_active, - text: eventData.text || '', - color, - textColor, - isProgressVisible: eventData.is_progress_visible, - }); - } - - if (eventType === 'web_app_setup_closing_behavior') { - setShouldConfirmClosing(eventData.need_confirmation); - } - - if (eventType === 'web_app_open_popup') { - if (popupParameters || !eventData.message.trim().length || !eventData.buttons?.length - || eventData.buttons.length > 3 || isRequestingPhone || isRequesingWriteAccess - || 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, - }); - - closeWebApp(); - } - - if (eventType === 'web_app_request_phone') { - if (popupParameters || isRequesingWriteAccess || 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)); - } - } - - const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + const MoreMenuButton: + FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> = useMemo(() => { return ({ onTrigger, isOpen: isMenuOpen }) => ( ); - }, [isMobile]); + }, [isMobile, supportMultiTabMode]); + + function renderMenuItems() { + return ( + <> + {chat && bot && chat.id !== bot.id && ( + {lang('BotWebViewOpenBot')} + )} + {lang('WebApp.ReloadPage')} + {isSettingsButtonVisible && ( + + {lang('Settings')} + + )} + {bot?.isAttachBot && ( + + {lang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} + + )} + + ); + } + + function renderMoreMenu() { + return ( + + {renderMenuItems()} + + ); + } + + function renderDropdownMoreMenu() { + return ( + + {renderMenuItems()} + + ); + } const backButtonClassName = buildClassName( styles.closeIcon, @@ -514,7 +377,153 @@ const WebAppModal: FC = ({ return adaptedLuma > LUMA_THRESHOLD ? 'color-text' : 'color-background'; }, [headerColor, theme]); + function renderTabCurveBorder(className: string) { + return ( + + + + ); + } + + function renderActiveTab() { + const style = buildStyle( + headerTextVar && `--color-header-text: var(--${headerTextVar})`, + headerColor && `--active-tab-background: ${headerColor}`, + ); + return ( +
+ {renderTabCurveBorder(styles.tabButtonLeftCurve)} +
+
+ + +
+ {attachBot?.shortName ?? bot?.firstName} +
+ +
+ {renderTabCurveBorder(styles.tabButtonRightCurve)} +
+ ); + } + + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + useHorizontalScroll(containerRef, !isOpen || !isMaximizedState || !(containerRef.current)); + + function renderTabs() { + return ( +
+ {tabs?.map((tab) => ( + tab.isOpen ? ( + renderActiveTab() + ) : ( + handleTabClick(tab)} + /> + ) + ))} +
+ ); + } + function renderHeader() { + return ( +
+ {!supportMultiTabMode + ? renderSinglePageModeHeader() + : (isMaximizedState ? renderMultiTabHeader() : )} +
+ ); + } + + function renderMultiTabHeader() { + return ( +
+ + {renderTabs()} + {renderMoreMenu()} + + {/* + */} + + +
+ ); + } + + function renderSinglePageModeHeader() { return (
= ({
{attachBot?.shortName ?? bot?.firstName}
- - {chat && bot && chat.id !== bot.id && ( - {lang('BotWebViewOpenBot')} - )} - {lang('WebApp.ReloadPage')} - {isSettingsButtonVisible && ( - - {lang('Settings')} - - )} - {bot?.isAttachBot && ( - - {lang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} - - )} - + {renderDropdownMoreMenu()}
); } - const prevMainButtonColor = usePreviousDeprecated(mainButton?.color, true); - const prevMainButtonTextColor = usePreviousDeprecated(mainButton?.textColor, true); - const prevMainButtonIsActive = usePreviousDeprecated(mainButton && Boolean(mainButton.isActive), true); - const prevMainButtonText = usePreviousDeprecated(mainButton?.text, true); - - const mainButtonCurrentColor = mainButton?.color || prevMainButtonColor; - const mainButtonCurrentTextColor = mainButton?.textColor || prevMainButtonTextColor; - const mainButtonCurrentIsActive = mainButton?.isActive !== undefined ? mainButton.isActive : prevMainButtonIsActive; - const mainButtonCurrentText = mainButton?.text || prevMainButtonText; - - const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false); - const [shouldHideButton, setShouldHideButton] = useState(true); - - const buttonChangeTimeout = useRef>(); - - useEffect(() => { - if (buttonChangeTimeout.current) clearTimeout(buttonChangeTimeout.current); - if (!shouldShowMainButton) { - setShouldDecreaseWebFrameSize(false); - buttonChangeTimeout.current = setTimeout(() => { - setShouldHideButton(true); - }, MAIN_BUTTON_ANIMATION_TIME); - } else { - setShouldHideButton(false); - buttonChangeTimeout.current = setTimeout(() => { - setShouldDecreaseWebFrameSize(true); - }, MAIN_BUTTON_ANIMATION_TIME); - } - }, [setShouldDecreaseWebFrameSize, shouldShowMainButton]); - return ( - - {isOpen && ( - <> -