Mini Apps: Support secondary button (#5074)

This commit is contained in:
Alexander Zinchuk 2024-11-02 21:10:49 +04:00
parent e2a717dc29
commit 0eb0784165
3 changed files with 259 additions and 60 deletions

View File

@ -44,19 +44,19 @@
display: none; display: none;
} }
.secondary-button,
.main-button { .main-button {
position: absolute; flex-grow: 1;
bottom: 0; margin: 0.5rem;
border-radius: 0;
z-index: 1;
transform: translateY(100%); transform: translateY(100%);
transition-property: background-color, color, transform; opacity: 0;
transition-property: background-color, color, transform, margin-inline, flex-grow, opacity;
transition-duration: 0.25s; transition-duration: 0.25s;
transition-timing-function: ease-in-out; transition-timing-function: ease-in-out;
&.visible { &.visible {
transform: translateY(0); transform: translateY(0);
opacity: 1;
} }
&.hidden { &.hidden {
@ -64,9 +64,84 @@
} }
} }
.buttons-container {
display: flex;
justify-content: space-between;
position: relative;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition-property: height;
transition-duration: 0.25s;
transition-timing-function: ease-in-out;
height: 0rem;
&.one-row {
align-items: center;
height: 4rem;
}
&.two-rows {
height: 8rem;
}
&.left-to-right {
flex-direction: row;
}
&.right-to-left {
flex-direction: row-reverse;
}
&.top-to-bottom,
&.bottom-to-top {
.secondary-button,
.main-button {
position: absolute;
left: 0rem;
right: 0rem;
}
}
&.top-to-bottom {
.secondary-button {
top: 0rem;
}
.main-button {
bottom: 0rem;
}
}
&.bottom-to-top {
.main-button {
top: 0rem;
}
.secondary-button {
bottom: 0rem;
}
}
}
.hide-horizontal {
.secondary-button,
.main-button {
transform: translateY(0);
flex-grow: 0;
margin-inline: 0;
padding-inline: 0;
width: 0;
white-space: nowrap;
overflow: hidden;
&.visible {
margin-inline: 0.5rem;
flex-grow: 1;
}
}
}
.secondary-button-spinner,
.main-button-spinner { .main-button-spinner {
position: absolute; position: absolute;
right: 1rem;
} }
.web-app-popup { .web-app-popup {

View File

@ -23,10 +23,10 @@ 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 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 usePreviousDeprecated from '../../../hooks/usePreviousDeprecated';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
import usePopupLimit from './hooks/usePopupLimit'; import usePopupLimit from './hooks/usePopupLimit';
import useWebAppFrame from './hooks/useWebAppFrame'; import useWebAppFrame from './hooks/useWebAppFrame';
@ -45,6 +45,7 @@ type WebAppButton = {
color: string; color: string;
textColor: string; textColor: string;
isProgressVisible: boolean; isProgressVisible: boolean;
position?: 'left' | 'right' | 'top' | 'bottom';
}; };
export type OwnProps = { export type OwnProps = {
@ -113,12 +114,14 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
updateWebApp, updateWebApp,
} = getActions(); } = getActions();
const [mainButton, setMainButton] = useState<WebAppButton | undefined>(); const [mainButton, setMainButton] = useState<WebAppButton | undefined>();
const [secondaryButton, setSecondaryButton] = useState<WebAppButton | undefined>();
const [isLoaded, markLoaded, markUnloaded] = useFlag(false); const [isLoaded, markLoaded, markUnloaded] = useFlag(false);
const [popupParameters, setPopupParameters] = useState<PopupOptions | undefined>(); const [popupParameters, setPopupParameters] = useState<PopupOptions | undefined>();
const [isRequestingPhone, setIsRequestingPhone] = useState(false); const [isRequestingPhone, setIsRequestingPhone] = useState(false);
const [isRequestingWriteAccess, setIsRequestingWriteAccess] = useState(false); const [isRequestingWriteAccess, setIsRequestingWriteAccess] = useState(false);
const [bottomBarColor, setBottomBarColor] = useState<string | undefined>();
const { const {
unlockPopupsAt, handlePopupOpened, handlePopupClosed, unlockPopupsAt, handlePopupOpened, handlePopupClosed,
} = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY);
@ -147,6 +150,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
useEffect(() => { useEffect(() => {
const themeParams = extractCurrentThemeParams(); const themeParams = extractCurrentThemeParams();
setBottomBarColor(themeParams.secondary_bg_color);
updateCurrentWebApp({ headerColor: themeParams.bg_color, backgroundColor: themeParams.bg_color }); updateCurrentWebApp({ headerColor: themeParams.bg_color, backgroundColor: themeParams.bg_color });
}, []); }, []);
@ -169,7 +173,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
if (isActive) registerReloadFrameCallback(reloadFrame); if (isActive) registerReloadFrameCallback(reloadFrame);
}, [reloadFrame, registerReloadFrameCallback, isActive]); }, [reloadFrame, registerReloadFrameCallback, isActive]);
const shouldShowMainButton = mainButton?.isVisible && mainButton.text.trim().length > 0; const isMainButtonVisible = mainButton?.isVisible && mainButton.text.trim().length > 0;
const isSecondaryButtonVisible = secondaryButton?.isVisible && secondaryButton.text.trim().length > 0;
const handleHideCloseModal = useLastCallback(() => { const handleHideCloseModal = useLastCallback(() => {
updateCurrentWebApp({ isCloseModalOpen: false }); updateCurrentWebApp({ isCloseModalOpen: false });
@ -191,6 +196,12 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}); });
}); });
const handleSecondaryButtonClick = useLastCallback(() => {
sendEvent({
eventType: 'secondary_button_pressed',
});
});
const handleAppPopupClose = useLastCallback((buttonId?: string) => { const handleAppPopupClose = useLastCallback((buttonId?: string) => {
setPopupParameters(undefined); setPopupParameters(undefined);
handlePopupClosed(); handlePopupClosed();
@ -241,13 +252,6 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}, ANIMATION_WAIT); }, ANIMATION_WAIT);
}, [theme]); }, [theme]);
// Notify view that height changed
useSyncEffect(() => {
setTimeout(() => {
sendViewport();
}, ANIMATION_WAIT);
}, [mainButton?.isVisible, sendViewport]);
useSyncEffect(([prevIsPaymentModalOpen]) => { useSyncEffect(([prevIsPaymentModalOpen]) => {
if (isPaymentModalOpen === prevIsPaymentModalOpen) return; if (isPaymentModalOpen === prevIsPaymentModalOpen) return;
if (webApp?.slug && !isPaymentModalOpen && paymentStatus) { if (webApp?.slug && !isPaymentModalOpen && paymentStatus) {
@ -362,6 +366,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
setIsRequestingPhone(false); setIsRequestingPhone(false);
setIsRequestingWriteAccess(false); setIsRequestingWriteAccess(false);
setMainButton(undefined); setMainButton(undefined);
setSecondaryButton(undefined);
updateCurrentWebApp({ updateCurrentWebApp({
isSettingsButtonVisible: false, isSettingsButtonVisible: false,
shouldConfirmClosing: false, shouldConfirmClosing: false,
@ -399,6 +404,10 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
calculateHeaderColor(eventData.color_key, eventData.color); calculateHeaderColor(eventData.color_key, eventData.color);
} }
if (eventType === 'web_app_set_bottom_bar_color') {
setBottomBarColor(eventData.color);
}
if (eventType === 'web_app_data_send') { if (eventType === 'web_app_data_send') {
closeActiveWebApp(); closeActiveWebApp();
sendWebViewData({ sendWebViewData({
@ -409,19 +418,32 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
} }
if (eventType === 'web_app_setup_main_button') { if (eventType === 'web_app_setup_main_button') {
const themeParams = extractCurrentThemeParams(); const color = eventData.color;
const color = validateHexColor(eventData.color) ? eventData.color : themeParams.button_color; const textColor = eventData.text_color;
const textColor = validateHexColor(eventData.text_color) ? eventData.text_color : themeParams.text_color;
setMainButton({ setMainButton({
isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length), isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length),
isActive: eventData.is_active, isActive: eventData.is_active,
text: eventData.text || '', text: eventData.text,
color, color,
textColor, textColor,
isProgressVisible: eventData.is_progress_visible, isProgressVisible: eventData.is_progress_visible,
}); });
} }
if (eventType === 'web_app_setup_secondary_button') {
const color = eventData.color;
const textColor = eventData.text_color;
setSecondaryButton({
isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length),
isActive: eventData.is_active,
text: eventData.text,
color,
textColor,
isProgressVisible: eventData.is_progress_visible,
position: eventData.position,
});
}
if (eventType === 'web_app_setup_closing_behavior') { if (eventType === 'web_app_setup_closing_behavior') {
updateCurrentWebApp({ shouldConfirmClosing: true }); updateCurrentWebApp({ shouldConfirmClosing: true });
} }
@ -478,39 +500,99 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
} }
} }
const prevMainButtonColor = usePreviousDeprecated(mainButton?.color, true); const mainButtonCurrentColor = useCurrentOrPrev(mainButton?.color, true);
const prevMainButtonTextColor = usePreviousDeprecated(mainButton?.textColor, true); const mainButtonCurrentTextColor = useCurrentOrPrev(mainButton?.textColor, true);
const prevMainButtonIsActive = usePreviousDeprecated(mainButton && Boolean(mainButton.isActive), true); const mainButtonCurrentIsActive = useCurrentOrPrev(mainButton && Boolean(mainButton.isActive), true);
const prevMainButtonText = usePreviousDeprecated(mainButton?.text, true); const mainButtonCurrentText = useCurrentOrPrev(mainButton?.text, true);
const mainButtonCurrentColor = mainButton?.color || prevMainButtonColor; const secondaryButtonCurrentPosition = useCurrentOrPrev(secondaryButton?.position, true);
const mainButtonCurrentTextColor = mainButton?.textColor || prevMainButtonTextColor; const secondaryButtonCurrentColor = useCurrentOrPrev(secondaryButton?.color, true);
const mainButtonCurrentIsActive = mainButton?.isActive !== undefined ? mainButton.isActive : prevMainButtonIsActive; const secondaryButtonCurrentTextColor = useCurrentOrPrev(secondaryButton?.textColor, true);
const mainButtonCurrentText = mainButton?.text || prevMainButtonText; const secondaryButtonCurrentIsActive = useCurrentOrPrev(secondaryButton && Boolean(secondaryButton.isActive), true);
const secondaryButtonCurrentText = useCurrentOrPrev(secondaryButton?.text, true);
const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false); const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false);
const [shouldHideButton, setShouldHideButton] = useState(true); const [shouldHideMainButton, setShouldHideMainButton] = useState(true);
const [shouldHideSecondaryButton, setShouldHideSecondaryButton] = useState(true);
const [shouldShowMainButton, setShouldShowMainButton] = useState(false);
const [shouldShowSecondaryButton, setShouldShowSecondaryButton] = useState(false);
const buttonChangeTimeout = useRef<ReturnType<typeof setTimeout>>(); // Notify view that height changed
useSyncEffect(() => {
setTimeout(() => {
sendViewport();
}, ANIMATION_WAIT);
}, [shouldShowSecondaryButton, shouldHideSecondaryButton,
shouldShowMainButton, shouldShowMainButton,
secondaryButton?.position, sendViewport]);
const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom';
const isHorizontalLayout = !isVerticalLayout;
const rowsCount = (isVerticalLayout && shouldShowMainButton && shouldShowSecondaryButton) ? 2
: shouldShowMainButton || shouldShowSecondaryButton ? 1 : 0;
const hideDirection = (isHorizontalLayout
&& (!shouldHideMainButton && !shouldHideSecondaryButton)) ? 'horizontal' : 'vertical';
const mainButtonChangeTimeout = useRef<ReturnType<typeof setTimeout>>();
const mainButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonChangeTimeout = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonFastTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { useEffect(() => {
if (buttonChangeTimeout.current) clearTimeout(buttonChangeTimeout.current); if (mainButtonChangeTimeout.current) clearTimeout(mainButtonChangeTimeout.current);
if (!shouldShowMainButton) { if (mainButtonFastTimeout.current) clearTimeout(mainButtonFastTimeout.current);
setShouldDecreaseWebFrameSize(false);
buttonChangeTimeout.current = setTimeout(() => { if (isMainButtonVisible) {
setShouldHideButton(true); mainButtonFastTimeout.current = setTimeout(() => {
}, MAIN_BUTTON_ANIMATION_TIME); setShouldShowMainButton(true);
} else { }, 35);
setShouldHideButton(false); setShouldHideMainButton(false);
buttonChangeTimeout.current = setTimeout(() => { mainButtonChangeTimeout.current = setTimeout(() => {
setShouldDecreaseWebFrameSize(true); setShouldDecreaseWebFrameSize(true);
}, MAIN_BUTTON_ANIMATION_TIME); }, MAIN_BUTTON_ANIMATION_TIME);
} }
}, [setShouldDecreaseWebFrameSize, shouldShowMainButton]);
if (!isMainButtonVisible) {
setShouldShowMainButton(false);
mainButtonChangeTimeout.current = setTimeout(() => {
setShouldHideMainButton(true);
}, MAIN_BUTTON_ANIMATION_TIME);
}
}, [isMainButtonVisible]);
useEffect(() => {
if (secondaryButtonChangeTimeout.current) clearTimeout(secondaryButtonChangeTimeout.current);
if (secondaryButtonFastTimeout.current) clearTimeout(secondaryButtonFastTimeout.current);
if (isSecondaryButtonVisible) {
secondaryButtonFastTimeout.current = setTimeout(() => {
setShouldShowSecondaryButton(true);
}, 35);
setShouldHideSecondaryButton(false);
secondaryButtonChangeTimeout.current = setTimeout(() => {
setShouldDecreaseWebFrameSize(true);
}, MAIN_BUTTON_ANIMATION_TIME);
}
if (!isSecondaryButtonVisible) {
setShouldShowSecondaryButton(false);
secondaryButtonChangeTimeout.current = setTimeout(() => {
setShouldHideSecondaryButton(true);
}, MAIN_BUTTON_ANIMATION_TIME);
}
}, [isSecondaryButtonVisible]);
useEffect(() => {
if (!shouldShowSecondaryButton && !shouldShowMainButton) {
setShouldDecreaseWebFrameSize(false);
}
}, [setShouldDecreaseWebFrameSize, shouldShowSecondaryButton, shouldShowMainButton]);
const frameWidth = frameSize?.width || 0; const frameWidth = frameSize?.width || 0;
let frameHeight = frameSize?.height || 0; let frameHeight = frameSize?.height || 0;
if (shouldDecreaseWebFrameSize) { frameHeight -= 3.5 * REM; } if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; }
const frameStyle = buildStyle( const frameStyle = buildStyle(
`left: ${0}px;`, `left: ${0}px;`,
`top: ${0}px;`, `top: ${0}px;`,
@ -543,19 +625,53 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
ref={frameRef} ref={frameRef}
/> />
{isMaximizedState && ( {isMaximizedState && (
<Button <div
style={`background-color: ${bottomBarColor};`}
className={buildClassName( className={buildClassName(
styles.mainButton, styles.buttonsContainer,
shouldShowMainButton && styles.visible, secondaryButtonCurrentPosition === 'left' && styles.leftToRight,
shouldHideButton && styles.hidden, secondaryButtonCurrentPosition === 'right' && styles.rightToLeft,
secondaryButtonCurrentPosition === 'top' && styles.topToBottom,
secondaryButtonCurrentPosition === 'bottom' && styles.bottomToTop,
hideDirection === 'horizontal' && styles.hideHorizontal,
rowsCount === 1 && styles.oneRow,
rowsCount === 2 && styles.twoRows,
)} )}
style={`background-color: ${mainButtonCurrentColor}; color: ${mainButtonCurrentTextColor}`}
disabled={!mainButtonCurrentIsActive}
onClick={handleMainButtonClick}
> >
{mainButtonCurrentText} <Button
{mainButton?.isProgressVisible && <Spinner className={styles.mainButtonSpinner} color="white" />} className={buildClassName(
</Button> styles.secondaryButton,
shouldShowSecondaryButton && !shouldHideSecondaryButton && styles.visible,
shouldHideSecondaryButton && styles.hidden,
)}
fluid
style={`background-color: ${secondaryButtonCurrentColor}; color: ${secondaryButtonCurrentTextColor}`}
disabled={!secondaryButtonCurrentIsActive && !secondaryButton?.isProgressVisible}
nonInteractive={secondaryButton?.isProgressVisible}
onClick={handleSecondaryButtonClick}
size="smaller"
>
{!secondaryButton?.isProgressVisible && secondaryButtonCurrentText}
{secondaryButton?.isProgressVisible
&& <Spinner className={styles.mainButtonSpinner} color="blue" />}
</Button>
<Button
className={buildClassName(
styles.mainButton,
shouldShowMainButton && !shouldHideMainButton && styles.visible,
shouldHideMainButton && styles.hidden,
)}
fluid
style={`background-color: ${mainButtonCurrentColor}; color: ${mainButtonCurrentTextColor}`}
disabled={!mainButtonCurrentIsActive && !mainButton?.isProgressVisible}
nonInteractive={mainButton?.isProgressVisible}
onClick={handleMainButtonClick}
size="smaller"
>
{!mainButton?.isProgressVisible && mainButtonCurrentText}
{mainButton?.isProgressVisible && <Spinner className={styles.mainButtonSpinner} color="white" />}
</Button>
</div>
) } ) }
<ConfirmDialog <ConfirmDialog
isOpen={isRequestingPhone} isOpen={isRequestingPhone}

View File

@ -16,6 +16,16 @@ type WebAppEvent<T, D> = D extends null ? {
eventData: D; eventData: D;
}; };
export type WebAppButtonOptions = {
is_visible: boolean;
is_active: boolean;
text: string;
color: string;
text_color: string;
is_progress_visible: boolean;
position?: 'left' | 'right' | 'top' | 'bottom';
};
export type WebAppInboundEvent = export type WebAppInboundEvent =
WebAppEvent<'iframe_ready', { WebAppEvent<'iframe_ready', {
reload_supported?: boolean; reload_supported?: boolean;
@ -23,14 +33,8 @@ export type WebAppInboundEvent =
WebAppEvent<'web_app_data_send', { WebAppEvent<'web_app_data_send', {
data: string; data: string;
}> | }> |
WebAppEvent<'web_app_setup_main_button', { WebAppEvent<'web_app_setup_main_button', WebAppButtonOptions> |
is_visible: boolean; WebAppEvent<'web_app_setup_secondary_button', WebAppButtonOptions> |
is_active: boolean;
text: string;
color: string;
text_color: string;
is_progress_visible: boolean;
}> |
WebAppEvent<'web_app_setup_back_button', { WebAppEvent<'web_app_setup_back_button', {
is_visible: boolean; is_visible: boolean;
}> | }> |
@ -52,6 +56,9 @@ export type WebAppInboundEvent =
impact_style?: 'light' | 'medium' | 'heavy'; impact_style?: 'light' | 'medium' | 'heavy';
notification_type?: 'error' | 'success' | 'warning'; notification_type?: 'error' | 'success' | 'warning';
}> | }> |
WebAppEvent<'web_app_set_bottom_bar_color', {
color: string;
}> |
WebAppEvent<'web_app_set_background_color', { WebAppEvent<'web_app_set_background_color', {
color: string; color: string;
}> | }> |
@ -160,5 +167,6 @@ export type WebAppOutboundEvent =
WebAppEvent<'biometry_token_updated', { WebAppEvent<'biometry_token_updated', {
status: 'updated' | 'removed' | 'failed'; status: 'updated' | 'removed' | 'failed';
}> | }> |
WebAppEvent<'main_button_pressed' | 'back_button_pressed' | 'settings_button_pressed' | 'scan_qr_popup_closed' WebAppEvent<'main_button_pressed' |
'secondary_button_pressed' | 'back_button_pressed' | 'settings_button_pressed' | 'scan_qr_popup_closed'
| 'reload_iframe', null>; | 'reload_iframe', null>;