From c0b89697cdde53d1c82d3489fe178025aa30af3c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 11 Dec 2024 18:16:15 +0100 Subject: [PATCH] Mini Apps: Support loading placeholder (#5285) --- src/api/gramjs/apiBuilders/bots.ts | 17 ++- src/api/gramjs/apiBuilders/misc.ts | 3 +- src/api/gramjs/apiBuilders/pathBytesToSvg.ts | 4 +- src/api/types/bots.ts | 9 ++ .../webApp/WebAppModalTabContent.module.scss | 37 +++++- .../modals/webApp/WebAppModalTabContent.tsx | 113 ++++++++++++------ src/global/types.ts | 2 - src/util/colors.ts | 4 + 8 files changed, 145 insertions(+), 44 deletions(-) diff --git a/src/api/gramjs/apiBuilders/bots.ts b/src/api/gramjs/apiBuilders/bots.ts index 7763fa5df..a6e7270a6 100644 --- a/src/api/gramjs/apiBuilders/bots.ts +++ b/src/api/gramjs/apiBuilders/bots.ts @@ -5,6 +5,7 @@ import type { ApiAttachBotIcon, ApiAttachMenuPeerType, ApiBotApp, + ApiBotAppSettings, ApiBotCommand, ApiBotInfo, ApiBotInlineMediaResult, @@ -16,11 +17,13 @@ import type { ApiMessagesBotApp, } from '../../types'; +import { numberToHexColor } from '../../../util/colors'; import { pick } from '../../../util/iteratees'; import { addDocumentToLocalDb } from '../helpers'; import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; import { omitVirtualClassFields } from './helpers'; import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messageContent'; +import { buildSvgPath } from './pathBytesToSvg'; import { buildApiPeerId } from './peers'; import { buildStickerFromDocument } from './symbols'; @@ -111,7 +114,7 @@ function buildApiAttachMenuIcon(icon: GramJs.AttachMenuBotIcon): ApiAttachBotIco export function buildApiBotInfo(botInfo: GramJs.BotInfo, chatId: string): ApiBotInfo { const { description, descriptionPhoto, descriptionDocument, userId, commands, menuButton, privacyPolicyUrl, - hasPreviewMedias, + hasPreviewMedias, appSettings, } = botInfo; const botId = userId && buildApiPeerId(userId, 'user'); @@ -129,6 +132,18 @@ export function buildApiBotInfo(botInfo: GramJs.BotInfo, chatId: string): ApiBot privacyPolicyUrl, commands: commandsArray?.length ? commandsArray : undefined, hasPreviewMedia: hasPreviewMedias, + appSettings: appSettings && buildBotAppSettings(appSettings), + }; +} + +export function buildBotAppSettings(settings: GramJs.BotAppSettings): ApiBotAppSettings { + const placeholderPath = settings.placeholderPath && buildSvgPath(settings.placeholderPath); + return { + backgroundColor: settings.backgroundColor ? numberToHexColor(settings.backgroundColor) : undefined, + backgroundDarkColor: settings.backgroundDarkColor ? numberToHexColor(settings.backgroundDarkColor) : undefined, + headerColor: settings.headerColor ? numberToHexColor(settings.headerColor) : undefined, + headerDarkColor: settings.headerDarkColor ? numberToHexColor(settings.headerDarkColor) : undefined, + placeholderPath, }; } diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index aa5faf980..4e721f521 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -10,6 +10,7 @@ import type { LangPackStringValue, } from '../../types'; +import { numberToHexColor } from '../../../util/colors'; import { buildCollectionByCallback, omit, omitUndefined, pick, } from '../../../util/iteratees'; @@ -297,7 +298,7 @@ export function buildApiLanguage(lang: GramJs.TypeLangPackLanguage): ApiLanguage function buildApiPeerColorSet(colorSet: GramJs.help.TypePeerColorSet) { if (colorSet instanceof GramJs.help.PeerColorSet) { - return colorSet.colors.map((color) => `#${color.toString(16).padStart(6, '0')}`); + return colorSet.colors.map((color) => numberToHexColor(color)); } return undefined; } diff --git a/src/api/gramjs/apiBuilders/pathBytesToSvg.ts b/src/api/gramjs/apiBuilders/pathBytesToSvg.ts index 02b96be77..60cc58298 100644 --- a/src/api/gramjs/apiBuilders/pathBytesToSvg.ts +++ b/src/api/gramjs/apiBuilders/pathBytesToSvg.ts @@ -6,12 +6,12 @@ const LOOKUP = 'AACAAAAHAAALMAAAQASTAVAAAZaacaaaahaaalmaaaqastava.az0123456789-, export function pathBytesToSvg(bytes: Buffer, width: number, height: number) { return TEMPLATE - .replace('{{path}}', buildPath(bytes)) + .replace('{{path}}', buildSvgPath(bytes)) .replace('{{width}}', String(width)) .replace('{{height}}', String(height)); } -function buildPath(bytes: Buffer) { +export function buildSvgPath(bytes: Buffer) { let path = 'M'; const len = bytes.length; diff --git a/src/api/types/bots.ts b/src/api/types/bots.ts index f231fb5a3..34a0cc82c 100644 --- a/src/api/types/bots.ts +++ b/src/api/types/bots.ts @@ -67,6 +67,14 @@ type ApiBotMenuButtonWebApp = { export type ApiBotMenuButton = ApiBotMenuButtonWebApp | ApiBotMenuButtonCommands; +export interface ApiBotAppSettings { + placeholderPath?: string; + backgroundColor?: string; + backgroundDarkColor?: string; + headerColor?: string; + headerDarkColor?: string; +} + export interface ApiBotInfo { botId: string; commands?: ApiBotCommand[]; @@ -76,6 +84,7 @@ export interface ApiBotInfo { menuButton: ApiBotMenuButton; privacyPolicyUrl?: string; hasPreviewMedia?: true; + appSettings?: ApiBotAppSettings; } export interface ApiBotPreviewMedia extends MediaContainer { diff --git a/src/components/modals/webApp/WebAppModalTabContent.module.scss b/src/components/modals/webApp/WebAppModalTabContent.module.scss index 34980f7f5..059ae0336 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.module.scss +++ b/src/components/modals/webApp/WebAppModalTabContent.module.scss @@ -13,7 +13,8 @@ z-index: 0; } -.loading-spinner { +.loadingPlaceholder { + width: 4rem; position: absolute; top: 50%; left: 50%; @@ -22,6 +23,40 @@ transition: opacity 0.25s ease-in-out; } +@keyframes fadeOpacityBreath { + 0% { + opacity: 0.4; + } + + 50% { + opacity: 0.2; + } + + 100% { + opacity: 0.4; + } +} + +.placeholderPath { + animation: 2s linear -0.8s infinite fadeOpacityBreath; +} + +.defaultPlaceholderGrid { + width: 4.5rem; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.25rem; + margin: 0 auto; + animation: 2s linear -0.8s infinite fadeOpacityBreath; +} + +.placeholderSquare { + width: 100%; + aspect-ratio: 1; + background-color: black; + border-radius: 0.375rem; +} + .hide { opacity: 0; } diff --git a/src/components/modals/webApp/WebAppModalTabContent.tsx b/src/components/modals/webApp/WebAppModalTabContent.tsx index 9326548f5..345ce9437 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.tsx +++ b/src/components/modals/webApp/WebAppModalTabContent.tsx @@ -1,11 +1,14 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, + useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types'; +import type { + ApiAttachBot, ApiBotAppSettings, ApiChat, ApiUser, +} from '../../../api/types'; import type { TabState, WebApp, WebAppModalStateType } from '../../../global/types'; import type { ThemeKey } from '../../../types'; import type { PopupOptions, WebAppInboundEvent, WebAppOutboundEvent } from '../../../types/webapp'; @@ -14,7 +17,7 @@ import { TME_LINK_PREFIX } from '../../../config'; import { convertToApiChatType } from '../../../global/helpers'; import { getWebAppKey } from '../../../global/helpers/bots'; import { - selectCurrentChat, selectTabState, selectTheme, selectUser, + selectCurrentChat, selectTabState, selectTheme, selectUser, selectUserFullInfo, selectWebApp, } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; @@ -69,6 +72,7 @@ export type OwnProps = { type StateProps = { chat?: ApiChat; bot?: ApiUser; + botAppSettings?: ApiBotAppSettings; attachBot?: ApiAttachBot; theme?: ThemeKey; isPaymentModalOpen?: boolean; @@ -113,6 +117,7 @@ const WebAppModalTabContent: FC = ({ frameSize, isMultiTabSupported, onContextMenuButtonClick, + botAppSettings, }) => { const { closeActiveWebApp, @@ -169,8 +174,13 @@ const WebAppModalTabContent: FC = ({ const activeWebApp = modal?.activeWebAppKey ? modal.openedWebApps[modal.activeWebAppKey] : undefined; const activeWebAppName = activeWebApp?.appName; const { - url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor, isBackButtonVisible, + url, buttonText, isBackButtonVisible, } = webApp || {}; + + const { + placeholderPath, + } = botAppSettings || {}; + const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen); const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen); @@ -184,11 +194,36 @@ const WebAppModalTabContent: FC = ({ updateWebApp({ key: webAppKey, update: updatedPartialWebApp }); }); + const themeParams = useMemo(() => { + return extractCurrentThemeParams(); + // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps + }, [theme]); + useEffect(() => { - const themeParams = extractCurrentThemeParams(); setBottomBarColor(themeParams.secondary_bg_color); - updateCurrentWebApp({ headerColor: themeParams.bg_color, backgroundColor: themeParams.bg_color }); - }, []); + }, [themeParams]); + + const themeBackgroundColor = themeParams.bg_color; + const [backgroundColorFromEvent, setBackgroundColorFromEvent] = useState(); + const backgroundColorFromSettings = theme === 'light' ? botAppSettings?.backgroundColor + : botAppSettings?.backgroundDarkColor; + + useEffect(() => { + const color = backgroundColorFromEvent || backgroundColorFromSettings || themeBackgroundColor; + + updateCurrentWebApp({ backgroundColor: color }); + }, [themeBackgroundColor, backgroundColorFromEvent, backgroundColorFromSettings]); + + const themeHeaderColor = themeParams.bg_color; + const [headerColorFromEvent, setHeaderColorFromEvent] = useState(); + const headerColorFromSettings = theme === 'light' ? botAppSettings?.headerColor + : botAppSettings?.headerDarkColor; + + useEffect(() => { + const color = headerColorFromEvent || headerColorFromSettings || themeHeaderColor; + + updateCurrentWebApp({ headerColor: color }); + }, [themeHeaderColor, headerColorFromEvent, headerColorFromSettings]); // eslint-disable-next-line no-null/no-null const frameRef = useRef(null); @@ -210,8 +245,8 @@ const WebAppModalTabContent: FC = ({ if (isActive) registerReloadFrameCallback(reloadFrame); }, [reloadFrame, registerReloadFrameCallback, isActive]); - const isMainButtonVisible = mainButton?.isVisible && mainButton.text.trim().length > 0; - const isSecondaryButtonVisible = secondaryButton?.isVisible && secondaryButton.text.trim().length > 0; + const isMainButtonVisible = isLoaded && mainButton?.isVisible && mainButton.text.trim().length > 0; + const isSecondaryButtonVisible = isLoaded && secondaryButton?.isVisible && secondaryButton.text.trim().length > 0; const handleHideCloseModal = useLastCallback(() => { updateCurrentWebApp({ isCloseModalOpen: false }); @@ -254,32 +289,8 @@ const WebAppModalTabContent: FC = ({ handleAppPopupClose(); }); - const calculateHeaderColor = useLastCallback( - (serverColorKey? : 'bg_color' | 'secondary_bg_color', serverColor? : string) => { - if (serverColorKey) { - const themeParams = extractCurrentThemeParams(); - const key = serverColorKey; - const newColor = themeParams[key]; - const color = validateHexColor(newColor) ? newColor : headerColor; - updateCurrentWebApp({ headerColor: color, serverHeaderColorKey: key }); - } - - if (serverColor) { - const color = validateHexColor(serverColor) ? serverColor : headerColor; - updateCurrentWebApp({ headerColor: color, serverHeaderColor: serverColor }); - } - }, - ); - - const updateHeaderColor = useLastCallback( - () => { - calculateHeaderColor(serverHeaderColorKey, serverHeaderColor); - }, - ); - const sendThemeCallback = useLastCallback(() => { sendTheme(); - updateHeaderColor(); }); // Notify view that theme changed @@ -526,13 +537,12 @@ const WebAppModalTabContent: FC = ({ } if (eventType === 'web_app_set_background_color') { - const themeParams = extractCurrentThemeParams(); - const color = validateHexColor(eventData.color) ? eventData.color : themeParams.bg_color; - updateCurrentWebApp({ backgroundColor: color }); + setBackgroundColorFromEvent(validateHexColor(eventData.color) ? eventData.color : undefined); } if (eventType === 'web_app_set_header_color') { - calculateHeaderColor(eventData.color_key, eventData.color); + const key = eventData.color_key; + setHeaderColorFromEvent(eventData.color || (key ? themeParams[key] : undefined)); } if (eventType === 'web_app_set_bottom_bar_color') { @@ -882,6 +892,32 @@ const WebAppModalTabContent: FC = ({ ); } + function renderDefaultPlaceholder() { + const className = buildClassName(styles.loadingPlaceholder, styles.defaultPlaceholderGrid, isLoaded && styles.hide); + return ( +
+
+
+
+
+
+ ); + } + + function renderPlaceholder() { + if (!placeholderPath) { + return renderDefaultPlaceholder(); + } + return ( + + + + ); + } + return (
= ({ )} > {isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()} - {!isMinimizedState && } + {!isMinimizedState && renderPlaceholder()}