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;
}
.secondary-button,
.main-button {
position: absolute;
bottom: 0;
border-radius: 0;
z-index: 1;
flex-grow: 1;
margin: 0.5rem;
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-timing-function: ease-in-out;
&.visible {
transform: translateY(0);
opacity: 1;
}
&.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 {
position: absolute;
right: 1rem;
}
.web-app-popup {

View File

@ -23,10 +23,10 @@ import { callApi } from '../../../api/gramjs';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useFlag from '../../../hooks/useFlag';
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';
@ -45,6 +45,7 @@ type WebAppButton = {
color: string;
textColor: string;
isProgressVisible: boolean;
position?: 'left' | 'right' | 'top' | 'bottom';
};
export type OwnProps = {
@ -113,12 +114,14 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
updateWebApp,
} = getActions();
const [mainButton, setMainButton] = useState<WebAppButton | undefined>();
const [secondaryButton, setSecondaryButton] = useState<WebAppButton | undefined>();
const [isLoaded, markLoaded, markUnloaded] = useFlag(false);
const [popupParameters, setPopupParameters] = useState<PopupOptions | undefined>();
const [isRequestingPhone, setIsRequestingPhone] = useState(false);
const [isRequestingWriteAccess, setIsRequestingWriteAccess] = useState(false);
const [bottomBarColor, setBottomBarColor] = useState<string | undefined>();
const {
unlockPopupsAt, handlePopupOpened, handlePopupClosed,
} = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY);
@ -147,6 +150,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
useEffect(() => {
const themeParams = extractCurrentThemeParams();
setBottomBarColor(themeParams.secondary_bg_color);
updateCurrentWebApp({ headerColor: themeParams.bg_color, backgroundColor: themeParams.bg_color });
}, []);
@ -169,7 +173,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
if (isActive) registerReloadFrameCallback(reloadFrame);
}, [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(() => {
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) => {
setPopupParameters(undefined);
handlePopupClosed();
@ -241,13 +252,6 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}, ANIMATION_WAIT);
}, [theme]);
// Notify view that height changed
useSyncEffect(() => {
setTimeout(() => {
sendViewport();
}, ANIMATION_WAIT);
}, [mainButton?.isVisible, sendViewport]);
useSyncEffect(([prevIsPaymentModalOpen]) => {
if (isPaymentModalOpen === prevIsPaymentModalOpen) return;
if (webApp?.slug && !isPaymentModalOpen && paymentStatus) {
@ -362,6 +366,7 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
setIsRequestingPhone(false);
setIsRequestingWriteAccess(false);
setMainButton(undefined);
setSecondaryButton(undefined);
updateCurrentWebApp({
isSettingsButtonVisible: false,
shouldConfirmClosing: false,
@ -399,6 +404,10 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
calculateHeaderColor(eventData.color_key, eventData.color);
}
if (eventType === 'web_app_set_bottom_bar_color') {
setBottomBarColor(eventData.color);
}
if (eventType === 'web_app_data_send') {
closeActiveWebApp();
sendWebViewData({
@ -409,19 +418,32 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}
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;
const color = eventData.color;
const textColor = eventData.text_color;
setMainButton({
isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length),
isActive: eventData.is_active,
text: eventData.text || '',
text: eventData.text,
color,
textColor,
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') {
updateCurrentWebApp({ shouldConfirmClosing: true });
}
@ -478,39 +500,99 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
}
}
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 = useCurrentOrPrev(mainButton?.color, true);
const mainButtonCurrentTextColor = useCurrentOrPrev(mainButton?.textColor, true);
const mainButtonCurrentIsActive = useCurrentOrPrev(mainButton && Boolean(mainButton.isActive), true);
const mainButtonCurrentText = useCurrentOrPrev(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 secondaryButtonCurrentPosition = useCurrentOrPrev(secondaryButton?.position, true);
const secondaryButtonCurrentColor = useCurrentOrPrev(secondaryButton?.color, true);
const secondaryButtonCurrentTextColor = useCurrentOrPrev(secondaryButton?.textColor, true);
const secondaryButtonCurrentIsActive = useCurrentOrPrev(secondaryButton && Boolean(secondaryButton.isActive), true);
const secondaryButtonCurrentText = useCurrentOrPrev(secondaryButton?.text, true);
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(() => {
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(() => {
if (mainButtonChangeTimeout.current) clearTimeout(mainButtonChangeTimeout.current);
if (mainButtonFastTimeout.current) clearTimeout(mainButtonFastTimeout.current);
if (isMainButtonVisible) {
mainButtonFastTimeout.current = setTimeout(() => {
setShouldShowMainButton(true);
}, 35);
setShouldHideMainButton(false);
mainButtonChangeTimeout.current = setTimeout(() => {
setShouldDecreaseWebFrameSize(true);
}, 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;
let frameHeight = frameSize?.height || 0;
if (shouldDecreaseWebFrameSize) { frameHeight -= 3.5 * REM; }
if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; }
const frameStyle = buildStyle(
`left: ${0}px;`,
`top: ${0}px;`,
@ -543,19 +625,53 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
ref={frameRef}
/>
{isMaximizedState && (
<Button
<div
style={`background-color: ${bottomBarColor};`}
className={buildClassName(
styles.mainButton,
shouldShowMainButton && styles.visible,
shouldHideButton && styles.hidden,
styles.buttonsContainer,
secondaryButtonCurrentPosition === 'left' && styles.leftToRight,
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}
{mainButton?.isProgressVisible && <Spinner className={styles.mainButtonSpinner} color="white" />}
</Button>
<Button
className={buildClassName(
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
isOpen={isRequestingPhone}

View File

@ -16,6 +16,16 @@ type WebAppEvent<T, D> = D extends null ? {
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 =
WebAppEvent<'iframe_ready', {
reload_supported?: boolean;
@ -23,14 +33,8 @@ export type WebAppInboundEvent =
WebAppEvent<'web_app_data_send', {
data: string;
}> |
WebAppEvent<'web_app_setup_main_button', {
is_visible: boolean;
is_active: boolean;
text: string;
color: string;
text_color: string;
is_progress_visible: boolean;
}> |
WebAppEvent<'web_app_setup_main_button', WebAppButtonOptions> |
WebAppEvent<'web_app_setup_secondary_button', WebAppButtonOptions> |
WebAppEvent<'web_app_setup_back_button', {
is_visible: boolean;
}> |
@ -52,6 +56,9 @@ export type WebAppInboundEvent =
impact_style?: 'light' | 'medium' | 'heavy';
notification_type?: 'error' | 'success' | 'warning';
}> |
WebAppEvent<'web_app_set_bottom_bar_color', {
color: string;
}> |
WebAppEvent<'web_app_set_background_color', {
color: string;
}> |
@ -160,5 +167,6 @@ export type WebAppOutboundEvent =
WebAppEvent<'biometry_token_updated', {
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>;