Mini Apps: Support loading placeholder (#5285)

This commit is contained in:
Alexander Zinchuk 2024-12-11 18:16:15 +01:00
parent 9290ea1d1c
commit c0b89697cd
8 changed files with 145 additions and 44 deletions

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
frameSize,
isMultiTabSupported,
onContextMenuButtonClick,
botAppSettings,
}) => {
const {
closeActiveWebApp,
@ -169,8 +174,13 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<string | undefined>();
const backgroundColorFromSettings = theme === 'light' ? botAppSettings?.backgroundColor
: botAppSettings?.backgroundDarkColor;
useEffect(() => {
const color = backgroundColorFromEvent || backgroundColorFromSettings || themeBackgroundColor;
updateCurrentWebApp({ backgroundColor: color });
}, [themeBackgroundColor, backgroundColorFromEvent, backgroundColorFromSettings]);
const themeHeaderColor = themeParams.bg_color;
const [headerColorFromEvent, setHeaderColorFromEvent] = useState<string | undefined>();
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<HTMLIFrameElement>(null);
@ -210,8 +245,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
}
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<OwnProps & StateProps> = ({
);
}
function renderDefaultPlaceholder() {
const className = buildClassName(styles.loadingPlaceholder, styles.defaultPlaceholderGrid, isLoaded && styles.hide);
return (
<div className={className}>
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
</div>
);
}
function renderPlaceholder() {
if (!placeholderPath) {
return renderDefaultPlaceholder();
}
return (
<svg
className={buildClassName(styles.loadingPlaceholder, isLoaded && styles.hide)}
viewBox="0 0 512 512"
>
<path className={styles.placeholderPath} d={placeholderPath} />
</svg>
);
}
return (
<div
ref={containerRef}
@ -892,7 +928,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
)}
>
{isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()}
{!isMinimizedState && <Spinner className={buildClassName(styles.loadingSpinner, isLoaded && styles.hide)} />}
{!isMinimizedState && renderPlaceholder()}
<iframe
className={buildClassName(
styles.frame,
@ -1045,6 +1081,8 @@ export default memo(withGlobal<OwnProps>(
const attachBot = activeBotId ? global.attachMenu.bots[activeBotId] : undefined;
const bot = activeBotId ? selectUser(global, activeBotId) : undefined;
const userFullInfo = activeBotId ? selectUserFullInfo(global, activeBotId) : undefined;
const botAppSettings = userFullInfo?.botInfo?.appSettings;
const chat = selectCurrentChat(global);
const theme = selectTheme(global);
const { isPaymentModalOpen, status: regularPaymentStatus } = selectTabState(global).payment;
@ -1060,6 +1098,7 @@ export default memo(withGlobal<OwnProps>(
isPaymentModalOpen: isPaymentModalOpen || Boolean(starsInputInvoice),
paymentStatus,
modalState,
botAppSettings,
};
},
)(WebAppModalTabContent));

View File

@ -1337,8 +1337,6 @@ export type WebApp = {
isCloseModalOpen?: boolean;
shouldConfirmClosing?: boolean;
headerColor?: string;
serverHeaderColor?: string;
serverHeaderColorKey?: 'bg_color' | 'secondary_bg_color';
backgroundColor?: string;
isBackButtonVisible?: boolean;
isSettingsButtonVisible?: boolean;

View File

@ -216,6 +216,10 @@ export const convertToRGBA = (color: number): string => {
return `rgba(${red}, ${green}, ${blue}, ${alphaFloat})`;
};
export const numberToHexColor = (color: number): string => {
return `#${color.toString(16).padStart(6, '0')}`;
};
export const getTextColor = (color: number): string => {
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;