Mini Apps: Multi window in desktop (#4921)

This commit is contained in:
Alexander Zinchuk 2024-09-27 16:11:23 +02:00
parent 9ddad6924f
commit faee4152f6
28 changed files with 2505 additions and 780 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" fill-rule="evenodd" stroke="null" d="M14.5 15.5a1 1 0 0 0 1-1v-7a1 1 0 1 0-2 0v6h-6a1 1 0 0 0 0 2zm3 1a1 1 0 0 0-1 1v7a1 1 0 1 0 2 0v-6h6a1 1 0 1 0 0-2z" clip-rule="evenodd"/><path fill="#000" fill-opacity=".3" fill-rule="evenodd" d="M14.5 15.5a1 1 0 0 0 1-1v-7a1 1 0 1 0-2 0v6h-6a1 1 0 0 0 0 2zm3 1a1 1 0 0 0-1 1v7a1 1 0 1 0 2 0v-6h6a1 1 0 1 0 0-2z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 9a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0v-6h6a1 1 0 0 0 0-2zm12 14a1 1 0 0 0 1-1v-7a1 1 0 1 0-2 0v6h-6a1 1 0 1 0 0 2z" clip-rule="evenodd"/><path fill="#000" fill-opacity=".3" fill-rule="evenodd" d="M58 0a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0V2h6a1 1 0 0 0 0-2zm12 14a1 1 0 0 0 1-1V6a1 1 0 1 0-2 0v6h-6a1 1 0 1 0 0 2z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 449 B

View File

@ -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."

View File

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

View File

@ -35,7 +35,7 @@ type ModalKey = keyof Pick<TabState,
'reportAdModal' |
'starsBalanceModal' |
'isStarPaymentModalOpen' |
'webApp' |
'webApps' |
'starsTransactionModal'
>;
@ -52,7 +52,6 @@ type Entries<T> = {
}[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,

View File

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

View File

@ -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<string, WebApp>;
};
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<HTMLDivElement>(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 (
<div className={styles.title}>
{title}
</div>
);
}
return (
<div
ref={ref}
className={buildClassName(
styles.root,
)}
>
<Button
className={styles.button}
round
color="translucent"
size="tiny"
ariaLabel={oldLang('Close')}
onClick={handleCloseClick}
>
<Icon className={styles.icon} name="close" />
</Button>
<AvatarList className={styles.avatars} size="mini" peers={peers} />
{renderTitle()}
<Button
className={buildClassName(
styles.windowStateButton,
'no-drag',
)}
round
color="translucent"
size="tiny"
onClick={handleExpandClick}
>
<Icon className={styles.stateIcon} name="expand-modal" />
</Button>
</div>
);
};
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));

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
.root {
height: 100%;
width: 100%;
}
.multi-tab {
display: flex;
flex-direction: column;
padding: 0;
z-index: 0;
}
.loading-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: opacity 0.25s ease-in-out;
}
.hide {
opacity: 0;
}
.frame {
transition: opacity 0.25s ease-in-out;
opacity: 1;
width: 100%;
height: 100%;
border: 0;
z-index: 1;
&.with-button {
height: calc(100% - 3.5rem);
}
&.hide {
opacity: 0;
}
}
.hidden {
display: none;
}
.main-button {
position: absolute;
bottom: 0;
border-radius: 0;
z-index: 1;
transform: translateY(100%);
transition-property: background-color, color, transform;
transition-duration: 0.25s;
transition-timing-function: ease-in-out;
&.visible {
transform: translateY(0);
}
&.hidden {
visibility: hidden;
}
}
.main-button-spinner {
position: absolute;
right: 1rem;
}
.web-app-popup {
:global(.modal-dialog) {
max-width: min(30rem, 100%);
}
&.without-title :global(.modal-content) {
padding-top: 0;
}
:global(.modal-content) {
padding-left: 2rem;
}
}

View File

@ -0,0 +1,648 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect,
useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types';
import type { TabState, WebApp } from '../../../global/types';
import type { ThemeKey } from '../../../types';
import type { PopupOptions, WebAppInboundEvent, 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';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { extractCurrentThemeParams, validateHexColor } from '../../../util/themeStyle';
import { callApi } from '../../../api/gramjs';
import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText';
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';
import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Modal from '../../ui/Modal';
import Spinner from '../../ui/Spinner';
import styles from './WebAppModalTabContent.module.scss';
type WebAppButton = {
isVisible: boolean;
isActive: boolean;
text: string;
color: string;
textColor: string;
isProgressVisible: boolean;
};
export type OwnProps = {
modal?: TabState['webApps'];
webApp?: WebApp;
registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void;
registerReloadFrameCallback: (callback: (url: string) => void) => void;
isDragging?: boolean;
frameSize?: { width: number; height: number };
isMultiTabSupported? : boolean;
};
type StateProps = {
chat?: ApiChat;
bot?: ApiUser;
attachBot?: ApiAttachBot;
theme?: ThemeKey;
isPaymentModalOpen?: boolean;
paymentStatus?: TabState['payment']['status'];
isMaximizedState: boolean;
};
const NBSP = '\u00A0';
const MAIN_BUTTON_ANIMATION_TIME = 250;
const ANIMATION_WAIT = 400;
const POPUP_SEQUENTIAL_LIMIT = 3;
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<string, string> = {
ok: 'OK',
cancel: 'Cancel',
close: 'Close',
};
const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
modal,
webApp,
bot,
theme,
isPaymentModalOpen,
paymentStatus,
registerSendEventCallback,
registerReloadFrameCallback,
isDragging,
isMaximizedState,
frameSize,
isMultiTabSupported,
}) => {
const {
closeActiveWebApp,
sendWebViewData,
toggleAttachBot,
openTelegramLink,
setWebAppPaymentSlug,
switchBotInline,
sharePhoneWithBot,
updateWebApp,
} = getActions();
const [mainButton, setMainButton] = 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 {
unlockPopupsAt, handlePopupOpened, handlePopupClosed,
} = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY);
const activeWebApp = modal?.activeWebApp;
const {
url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor,
} = webApp || {};
const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen);
const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen);
const webAppKey = webApp && getWebAppKey(webApp);
const activeWebAppKey = activeWebApp && getWebAppKey(activeWebApp);
const isActive = (activeWebApp && webApp) && activeWebAppKey === webAppKey;
const updateCurrentWebApp = useLastCallback((updatedPartialWebApp: Partial<WebApp>) => {
if (!webApp) return;
const updatedWebApp = {
...webApp,
...updatedPartialWebApp,
};
webApp = updatedWebApp;
updateWebApp({ webApp: updatedWebApp });
});
useEffect(() => {
const themeParams = extractCurrentThemeParams();
updateCurrentWebApp({ headerColor: themeParams.bg_color, backgroundColor: themeParams.bg_color });
}, []);
// eslint-disable-next-line no-null/no-null
const frameRef = useRef<HTMLIFrameElement>(null);
const lang = useOldLang();
const isOpen = modal?.isModalOpen || false;
const isSimple = Boolean(buttonText);
const {
reloadFrame, sendEvent, sendViewport, sendTheme,
} = useWebAppFrame(frameRef, isOpen, isSimple, handleEvent, webApp, markLoaded);
useEffect(() => {
if (isActive) registerSendEventCallback(sendEvent);
}, [sendEvent, registerSendEventCallback, isActive]);
useEffect(() => {
if (isActive) registerReloadFrameCallback(reloadFrame);
}, [reloadFrame, registerReloadFrameCallback, isActive]);
const shouldShowMainButton = mainButton?.isVisible && mainButton.text.trim().length > 0;
const handleHideCloseModal = useLastCallback(() => {
updateCurrentWebApp({ isCloseModalOpen: false });
});
const handleConfirmCloseModal = useLastCallback(() => {
updateCurrentWebApp({ shouldConfirmClosing: false, isCloseModalOpen: false });
setTimeout(() => {
closeActiveWebApp();
}, ANIMATION_WAIT);
});
const handleHideRemoveModal = useLastCallback(() => {
updateCurrentWebApp({ isRemoveModalOpen: false });
});
const handleMainButtonClick = useLastCallback(() => {
sendEvent({
eventType: 'main_button_pressed',
});
});
const handleAppPopupClose = useLastCallback((buttonId?: string) => {
setPopupParameters(undefined);
handlePopupClosed();
sendEvent({
eventType: 'popup_closed',
eventData: {
button_id: buttonId,
},
});
});
const handleAppPopupModalClose = useLastCallback(() => {
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
useSyncEffect(() => {
setTimeout(() => {
sendThemeCallback();
}, 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) {
sendEvent({
eventType: 'invoice_closed',
eventData: {
slug: webApp.slug,
status: paymentStatus,
},
});
setWebAppPaymentSlug({
slug: undefined,
});
}
}, [isPaymentModalOpen, paymentStatus, sendEvent, webApp?.slug]);
const handleRemoveAttachBot = useLastCallback(() => {
toggleAttachBot({
botId: bot!.id,
isEnabled: false,
});
closeActiveWebApp();
});
const handleRejectPhone = useLastCallback(() => {
setIsRequestingPhone(false);
handlePopupClosed();
sendEvent({
eventType: 'phone_requested',
eventData: {
status: 'cancelled',
},
});
});
const handleAcceptPhone = useLastCallback(() => {
sharePhoneWithBot({ botId: bot!.id });
setIsRequestingPhone(false);
handlePopupClosed();
sendEvent({
eventType: 'phone_requested',
eventData: {
status: 'sent',
},
});
});
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,
},
});
}
useEffect(() => {
if (!isOpen) {
setPopupParameters(undefined);
setIsRequestingPhone(false);
setIsRequestingWriteAccess(false);
setMainButton(undefined);
updateCurrentWebApp({
isSettingsButtonVisible: false,
shouldConfirmClosing: false,
isBackButtonVisible: false,
isCloseModalOpen: false,
isRemoveModalOpen: false,
});
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 });
closeActiveWebApp();
}
if (eventType === 'web_app_setup_back_button') {
updateCurrentWebApp({ isBackButtonVisible: eventData.is_visible });
}
if (eventType === 'web_app_setup_settings_button') {
updateCurrentWebApp({ isSettingsButtonVisible: eventData.is_visible });
}
if (eventType === 'web_app_set_background_color') {
const themeParams = extractCurrentThemeParams();
const color = validateHexColor(eventData.color) ? eventData.color : themeParams.bg_color;
updateCurrentWebApp({ backgroundColor: color });
}
if (eventType === 'web_app_set_header_color') {
calculateHeaderColor(eventData.color_key, eventData.color);
}
if (eventType === 'web_app_data_send') {
closeActiveWebApp();
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') {
updateCurrentWebApp({ shouldConfirmClosing: true });
}
if (eventType === 'web_app_open_popup') {
if (popupParameters || !eventData.message.trim().length || !eventData.buttons?.length
|| eventData.buttons.length > 3 || isRequestingPhone || isRequestingWriteAccess
|| 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,
});
closeActiveWebApp();
}
if (eventType === 'web_app_request_phone') {
if (popupParameters || isRequestingWriteAccess || 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 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<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(() => {
setShouldDecreaseWebFrameSize(true);
}, MAIN_BUTTON_ANIMATION_TIME);
}
}, [setShouldDecreaseWebFrameSize, shouldShowMainButton]);
const frameWidth = frameSize?.width || 0;
let frameHeight = frameSize?.height || 0;
if (shouldDecreaseWebFrameSize) { frameHeight -= 3.5 * REM; }
const frameStyle = buildStyle(
`left: ${0}px;`,
`top: ${0}px;`,
`width: ${frameWidth}px;`,
`height: ${frameHeight}px;`,
isDragging ? 'pointer-events: none;' : '',
);
return (
<div
className={buildClassName(
styles.root,
!isActive && styles.hidden,
isMultiTabSupported && styles.multiTab,
)}
>
{isMaximizedState && <Spinner className={buildClassName(styles.loadingSpinner, isLoaded && styles.hide)} />}
<iframe
className={buildClassName(
styles.frame,
shouldDecreaseWebFrameSize && styles.withButton,
!isLoaded && styles.hide,
)}
style={frameSize ? frameStyle : undefined}
src={url}
title={`${bot?.firstName} Web App`}
sandbox={SANDBOX_ATTRIBUTES}
allow="camera; microphone; geolocation;"
allowFullScreen
ref={frameRef}
/>
{isMaximizedState && (
<Button
className={buildClassName(
styles.mainButton,
shouldShowMainButton && styles.visible,
shouldHideButton && styles.hidden,
)}
style={`background-color: ${mainButtonCurrentColor}; color: ${mainButtonCurrentTextColor}`}
disabled={!mainButtonCurrentIsActive}
onClick={handleMainButtonClick}
>
{mainButtonCurrentText}
{mainButton?.isProgressVisible && <Spinner className={styles.mainButtonSpinner} color="white" />}
</Button>
) }
<ConfirmDialog
isOpen={isRequestingPhone}
onClose={handleRejectPhone}
title={lang('ShareYouPhoneNumberTitle')}
text={lang('AreYouSureShareMyContactInfoBot')}
confirmHandler={handleAcceptPhone}
confirmLabel={lang('ContactShare')}
/>
<ConfirmDialog
isOpen={isRequestingWriteAccess}
onClose={handleRejectWriteAccess}
title={lang('lng_bot_allow_write_title')}
text={lang('lng_bot_allow_write')}
confirmHandler={handleAcceptWriteAccess}
confirmLabel={lang('lng_bot_allow_write_confirm')}
/>
{popupParameters && (
<Modal
isOpen={Boolean(popupParameters)}
title={popupParameters.title || NBSP}
onClose={handleAppPopupModalClose}
hasCloseButton
className={
buildClassName(styles.webAppPopup, !popupParameters.title?.trim().length && styles.withoutTitle)
}
>
{popupParameters.message}
<div className="dialog-buttons mt-2">
{popupParameters.buttons.map((button) => (
<Button
key={button.id || button.type}
className="confirm-dialog-button"
color={button.type === 'destructive' ? 'danger' : 'primary'}
isText
size="smaller"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleAppPopupClose(button.id)}
>
{button.text || lang(DEFAULT_BUTTON_TEXT[button.type])}
</Button>
))}
</div>
</Modal>
)}
<ConfirmDialog
isOpen={isCloseModalOpen}
onClose={handleHideCloseModal}
title={lang('lng_bot_close_warning_title')}
text={lang('lng_bot_close_warning')}
confirmHandler={handleConfirmCloseModal}
confirmIsDestructive
confirmLabel={lang('lng_bot_close_warning_sure')}
/>
<ConfirmDialog
isOpen={isRemoveModalOpen}
onClose={handleHideRemoveModal}
title={lang('BotRemoveFromMenuTitle')}
textParts={renderText(lang('BotRemoveFromMenu', bot?.firstName), ['simple_markdown'])}
confirmHandler={handleRemoveAttachBot}
confirmIsDestructive
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const { botId: activeBotId } = modal?.activeWebApp || {};
const isMaximizedState = modal?.modalState === 'maximized';
const attachBot = activeBotId ? global.attachMenu.bots[activeBotId] : undefined;
const bot = activeBotId ? selectUser(global, activeBotId) : undefined;
const chat = selectCurrentChat(global);
const theme = selectTheme(global);
const { isPaymentModalOpen, status } = selectTabState(global).payment;
const { isStarPaymentModalOpen } = selectTabState(global);
return {
attachBot,
bot,
chat,
theme,
isPaymentModalOpen: isPaymentModalOpen || isStarPaymentModalOpen,
paymentStatus: status,
isMaximizedState,
};
},
)(WebAppModalTabContent));

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { WebApp } from '../../../../global/types';
import type { WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/webapp';
import { extractCurrentThemeParams } from '../../../../util/themeStyle';
@ -35,6 +36,7 @@ const useWebAppFrame = (
isOpen: boolean,
isSimpleView: boolean,
onEvent: (event: WebAppInboundEvent) => void,
webApp?: WebApp,
onLoad?: () => void,
) => {
const {
@ -128,6 +130,12 @@ const useWebAppFrame = (
if (ignoreEventsRef.current) {
return;
}
const contentWindow = ref.current?.contentWindow;
const sourceWindow = event.source as Window;
if (contentWindow !== sourceWindow) {
return;
}
try {
const data = JSON.parse(event.data) as WebAppInboundEvent;
@ -138,7 +146,7 @@ const useWebAppFrame = (
}
if (eventType === 'web_app_close') {
closeWebApp();
if (webApp) closeWebApp({ webApp, skipClosingConfirmation: true });
}
if (eventType === 'web_app_request_viewport') {
@ -175,9 +183,9 @@ const useWebAppFrame = (
},
});
showNotification({
message: 'Clipboard access is not supported in this client yet',
});
// showNotification({
// message: 'Clipboard access is not supported in this client yet',
// });
}
if (eventType === 'web_app_open_scan_qr_popup') {
@ -214,7 +222,10 @@ const useWebAppFrame = (
} catch (err) {
// Ignore other messages
}
}, [isSimpleView, sendEvent, onEvent, sendCustomStyle, sendTheme, sendViewport, onLoad, windowSize.isResizing]);
}, [
isSimpleView, sendEvent, onEvent, sendCustomStyle, webApp,
sendTheme, sendViewport, onLoad, windowSize.isResizing, ref,
]);
useEffect(() => {
const { width, height, isResizing } = windowSize;
@ -227,7 +238,7 @@ const useWebAppFrame = (
useEffect(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [handleMessage]);
}, [handleMessage, ref]);
useEffect(() => {
if (isOpen && ref.current?.contentWindow) {

View File

@ -37,6 +37,7 @@ export type OwnProps = {
noBackdropClose?: boolean;
children: React.ReactNode;
style?: string;
dialogStyle?: string;
dialogRef?: React.RefObject<HTMLDivElement>;
isLowStackPriority?: boolean;
onClose: () => void;
@ -59,6 +60,7 @@ const Modal: FC<OwnProps> = ({
noBackdropClose,
children,
style,
dialogStyle,
isLowStackPriority,
onClose,
onCloseAnimationEnd,
@ -167,7 +169,7 @@ const Modal: FC<OwnProps> = ({
>
<div className="modal-container">
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
<div className="modal-dialog" ref={dialogRef}>
<div className="modal-dialog" ref={dialogRef} style={dialogStyle}>
{renderHeader()}
<div className={buildClassName('modal-content custom-scroll', contentClassName)} style={style}>
{children}

View File

@ -1,6 +1,8 @@
import type { InlineBotSettings } from '../../../types';
import type { RequiredGlobalActions } from '../../index';
import type { ActionReturnType, GlobalState, TabArgs } from '../../types';
import type {
ActionReturnType, GlobalState, TabArgs, WebApp,
} from '../../types';
import {
type ApiChat, type ApiChatType, type ApiContact, type ApiInputMessageReplyInfo, type ApiPeer, type ApiUrlAuthResult,
MAIN_THREAD_ID,
@ -16,13 +18,22 @@ import { debounce } from '../../../util/schedulers';
import { getServerTime } from '../../../util/serverTime';
import { extractCurrentThemeParams } from '../../../util/themeStyle';
import { callApi } from '../../../api/gramjs';
import {
getWebAppKey,
} from '../../helpers/bots';
import {
addActionHandler, getGlobal, setGlobal,
} from '../../index';
import {
removeBlockedUser, updateManagementProgress, updateUser, updateUserFullInfo,
} from '../../reducers';
import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots';
import {
activateWebAppIfOpen,
addWebAppToOpenList, clearOpenedWebApps, hasOpenedWebApps,
removeActiveWebAppFromOpenList, removeWebAppFromOpenList,
replaceInlineBotSettings, replaceInlineBotsIsLoading,
replaceIsWebAppModalOpen, replaceWebAppModalState, updateWebApp,
} from '../../reducers/bots';
import { updateTabState } from '../../reducers/tabs';
import {
selectBot,
@ -482,6 +493,8 @@ addActionHandler('requestSimpleWebView', async (global, actions, payload): Promi
tabId = getCurrentTabId(),
} = payload;
if (checkIfOpenOrActivate(global, botId, tabId, url)) return;
const bot = selectUser(global, botId);
if (!bot) return;
@ -513,13 +526,13 @@ addActionHandler('requestSimpleWebView', async (global, actions, payload): Promi
}
global = getGlobal();
global = updateTabState(global, {
webApp: {
url: webViewUrl,
botId,
buttonText,
},
}, tabId);
const newActiveApp: WebApp = {
requestUrl: url,
url: webViewUrl,
botId,
buttonText,
};
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global);
});
@ -529,6 +542,8 @@ addActionHandler('requestWebView', async (global, actions, payload): Promise<voi
tabId = getCurrentTabId(),
} = payload;
if (checkIfOpenOrActivate(global, botId, tabId, url)) return;
const bot = selectUser(global, botId);
if (!bot) return;
const peer = selectPeer(global, peerId);
@ -574,15 +589,16 @@ addActionHandler('requestWebView', async (global, actions, payload): Promise<voi
const { url: webViewUrl, queryId } = result;
global = getGlobal();
global = updateTabState(global, {
webApp: {
url: webViewUrl,
botId,
queryId,
replyInfo,
buttonText,
},
}, tabId);
const newActiveApp: WebApp = {
requestUrl: url,
url: webViewUrl,
botId,
peerId,
queryId,
replyInfo,
buttonText,
};
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global);
});
@ -592,6 +608,8 @@ addActionHandler('requestMainWebView', async (global, actions, payload): Promise
tabId = getCurrentTabId(),
} = payload;
if (checkIfOpenOrActivate(global, botId, tabId)) return;
const bot = selectUser(global, botId);
if (!bot) return;
const peer = selectPeer(global, peerId);
@ -629,14 +647,14 @@ addActionHandler('requestMainWebView', async (global, actions, payload): Promise
const { url: webViewUrl, queryId } = result;
global = getGlobal();
global = updateTabState(global, {
webApp: {
url: webViewUrl,
botId,
queryId,
buttonText: '',
},
}, tabId);
const newActiveApp: WebApp = {
url: webViewUrl,
botId,
peerId,
queryId,
buttonText: '',
};
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global);
});
@ -668,12 +686,26 @@ addActionHandler('loadPreviewMedias', async (global, actions, payload): Promise<
}
});
addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType => {
const {
webApp, tabId = getCurrentTabId(),
} = payload;
if (webApp) {
global = getGlobal();
global = addWebAppToOpenList(global, webApp, true, true, tabId);
setGlobal(global);
}
});
addActionHandler('requestAppWebView', async (global, actions, payload): Promise<void> => {
const {
botId, appName, startApp, theme, isWriteAllowed, isFromConfirm, shouldSkipBotTrustRequest,
tabId = getCurrentTabId(),
} = payload;
if (checkIfOpenOrActivate(global, botId, tabId, appName)) return;
const bot = selectUser(global, botId);
if (!bot) return;
@ -751,13 +783,19 @@ addActionHandler('requestAppWebView', async (global, actions, payload): Promise<
if (!url) return;
global = updateTabState(global, {
webApp: {
url,
botId,
buttonText: '',
},
}, tabId);
global = getGlobal();
const peerId = (peer ? peer.id : bot!.id);
const newActiveApp: WebApp = {
url,
peerId,
botId,
appName,
buttonText: '',
};
global = addWebAppToOpenList(global, newActiveApp, true, true, tabId);
setGlobal(global);
});
@ -783,7 +821,7 @@ addActionHandler('prolongWebView', async (global, actions, payload): Promise<voi
});
if (!result) {
actions.closeWebApp({ tabId });
actions.closeActiveWebApp({ tabId });
}
});
@ -799,25 +837,60 @@ addActionHandler('sendWebViewData', (global, actions, payload): ActionReturnType
});
});
addActionHandler('closeWebApp', (global, actions, payload): ActionReturnType => {
addActionHandler('updateWebApp', (global, actions, payload): ActionReturnType => {
const {
webApp, tabId = getCurrentTabId(),
} = payload;
return updateWebApp(global, webApp, tabId);
});
addActionHandler('closeActiveWebApp', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
webApp: undefined,
}, tabId);
global = removeActiveWebAppFromOpenList(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('closeWebApp', (global, actions, payload): ActionReturnType => {
const { webApp, skipClosingConfirmation, tabId = getCurrentTabId() } = payload || {};
global = removeWebAppFromOpenList(global, webApp, skipClosingConfirmation, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('closeWebAppModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
global = clearOpenedWebApps(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('changeWebAppModalState', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const newModalState = tabState.webApps.modalState === 'maximized' ? 'minimized' : 'maximized';
return replaceWebAppModalState(global, newModalState, tabId);
});
addActionHandler('setWebAppPaymentSlug', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
if (!tabState.webApp?.url) return undefined;
const activeWebApp = tabState.webApps.activeWebApp;
if (!activeWebApp?.url) return undefined;
return updateTabState(global, {
webApp: {
...tabState.webApp,
slug: payload.slug,
},
}, tabId);
const updatedApp = {
...activeWebApp,
slug: payload.slug,
};
return updateWebApp(global, updatedApp, tabId);
});
addActionHandler('cancelBotTrustRequest', (global, actions, payload): ActionReturnType => {
@ -875,6 +948,31 @@ addActionHandler('toggleAttachBot', async (global, actions, payload): Promise<vo
await callApi('toggleAttachBot', { bot, isWriteAllowed, isEnabled });
});
export function isWepAppOpened<T extends GlobalState>(
global: T, webApp: Partial<WebApp>, tabId: number,
) {
const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps;
const key = getWebAppKey(webApp);
if (!key) return false;
return openedWebApps[key];
}
export function checkIfOpenOrActivate<T extends GlobalState>(
global: T, botId: string, tabId: number, requestUrl?: string, webAppName?: string,
) {
const webAppForCheck = { botId, requestUrl, webAppName };
if (isWepAppOpened(global, webAppForCheck, tabId)) {
const key = getWebAppKey(webAppForCheck);
if (key) {
global = activateWebAppIfOpen(global, key, tabId);
setGlobal(global);
}
return true;
}
return false;
}
async function loadAttachBots<T extends GlobalState>(global: T, hash?: string) {
const result = await callApi('loadAttachBots', { hash });
if (!result) {

View File

@ -127,9 +127,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updateWebViewResultSent':
Object.values(global.byTabId).forEach((tabState) => {
if (tabState.webApp?.queryId === update.queryId) {
if (tabState.webApps.activeWebApp?.queryId === update.queryId) {
actions.resetDraftReplyInfo({ tabId: tabState.id });
actions.closeWebApp({ tabId: tabState.id });
actions.closeActiveWebApp({ tabId: tabState.id });
}
});
break;

View File

@ -0,0 +1,17 @@
import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addWebAppToOpenList } from '../../reducers/bots';
addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType => {
const {
webApp, tabId = getCurrentTabId(),
} = payload;
if (!webApp) return;
global = getGlobal();
global = addWebAppToOpenList(global, webApp, true, true, tabId);
setGlobal(global);
});

View File

@ -1,4 +1,7 @@
import type { ApiChatType, ApiPhoto } from '../../api/types';
import type {
WebApp,
} from '../types';
export function getBotCoverMediaHash(photo: ApiPhoto) {
return `photo${photo.id}?size=x`;
@ -11,3 +14,9 @@ export function convertToApiChatType(type: string): ApiChatType | undefined {
if (type === 'bots') return 'bots';
return undefined;
}
export function getWebAppKey(webApp: Partial<WebApp>) {
if (webApp.requestUrl) return webApp.requestUrl;
if (webApp.appName) return `${webApp.botId}?appName=${webApp.appName}`;
return webApp.botId;
}

View File

@ -325,6 +325,14 @@ export const INITIAL_TAB_STATE: TabState = {
byUsername: {},
},
webApps: {
openedWebApps: {},
openedOrderedKeys: [],
sessionKeys: [],
modalState: 'maximized',
isModalOpen: false,
},
globalSearch: {},
userSearch: {},

View File

@ -1,7 +1,10 @@
import type { InlineBotSettings } from '../../types';
import type { GlobalState, TabArgs } from '../types';
import type {
GlobalState, TabArgs, WebApp, WebAppModalStateType,
} from '../types';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { getWebAppKey } from '../helpers/bots';
import { selectTabState } from '../selectors';
import { updateTabState } from './tabs';
@ -32,3 +35,239 @@ export function replaceInlineBotsIsLoading<T extends GlobalState>(
},
}, tabId);
}
export function updateWebApp <T extends GlobalState>(
global: T, webApp: Partial<WebApp>,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps;
const key = webApp && getWebAppKey(webApp);
const originalWebApp = key ? openedWebApps[key] : undefined;
if (!originalWebApp) return global;
const updatedValue = {
...originalWebApp,
...webApp,
};
const updatedWebAppKey = getWebAppKey(updatedValue);
if (!updatedWebAppKey) return global;
const activeWebApp = currentTabState.webApps.activeWebApp;
const activeWebAppKey = activeWebApp && getWebAppKey(activeWebApp);
global = updateTabState(global, {
webApps: {
...currentTabState.webApps,
...updatedWebAppKey === activeWebAppKey && {
activeWebApp: updatedValue,
},
openedWebApps: {
...openedWebApps,
[updatedWebAppKey]: updatedValue,
},
},
}, tabId);
return global;
}
export function activateWebAppIfOpen<T extends GlobalState>(
global: T, webAppKey: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps;
if (!openedWebApps[webAppKey]) {
return global;
}
const newActiveWebApp = openedWebApps[webAppKey];
global = updateTabState(global, {
webApps: {
...currentTabState.webApps,
activeWebApp: newActiveWebApp,
modalState: 'maximized',
},
}, tabId);
return global;
}
export function addWebAppToOpenList<T extends GlobalState>(
global: T, webApp: WebApp,
makeActive: boolean = true, openModalIfNotOpen: boolean = true,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
const key = getWebAppKey(webApp);
if (!key) return global;
const newOpenedKeys = [...currentTabState.webApps.openedOrderedKeys];
if (!newOpenedKeys.includes(key)) newOpenedKeys.push(key);
const newSessionKeys = [...currentTabState.webApps.sessionKeys];
if (!newSessionKeys.includes(key)) newSessionKeys.push(key);
const openedWebApps = currentTabState.webApps.openedWebApps;
global = updateTabState(global, {
webApps: {
...currentTabState.webApps,
...makeActive && { activeWebApp: webApp },
isModalOpen: openModalIfNotOpen,
modalState: 'maximized',
openedWebApps: {
...openedWebApps,
[key]: webApp,
},
openedOrderedKeys: newOpenedKeys,
sessionKeys: newSessionKeys,
},
}, tabId);
return global;
}
export function removeActiveWebAppFromOpenList<T extends GlobalState>(
global: T, ...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
if (!currentTabState.webApps.activeWebApp) return global;
return removeWebAppFromOpenList(global, currentTabState.webApps.activeWebApp, false, tabId);
}
export function removeWebAppFromOpenList<T extends GlobalState>(
global: T, webApp: WebApp, skipClosingConfirmation?: boolean,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps;
if (!skipClosingConfirmation && webApp.shouldConfirmClosing) {
return updateWebApp(global, { ...webApp, isCloseModalOpen: true }, tabId);
}
const updatedOpenedWebApps = { ...openedWebApps };
const removingWebAppKey = getWebAppKey(webApp);
let newOpenedKeys = currentTabState.webApps.openedOrderedKeys;
if (removingWebAppKey) {
delete updatedOpenedWebApps[removingWebAppKey];
newOpenedKeys = currentTabState.webApps.openedOrderedKeys.filter((key) => key !== removingWebAppKey);
}
const activeWebApp = currentTabState.webApps.activeWebApp;
const isRemovedAppActive = activeWebApp && (getWebAppKey(activeWebApp) === getWebAppKey(webApp));
const openedWebAppsValues = Object.values(updatedOpenedWebApps);
const openedWebAppsCount = openedWebAppsValues.length;
global = updateTabState(global, {
webApps: {
...currentTabState.webApps,
...isRemovedAppActive && {
activeWebApp: openedWebAppsCount
? openedWebAppsValues[openedWebAppsCount - 1] : undefined,
},
openedWebApps: updatedOpenedWebApps,
openedOrderedKeys: newOpenedKeys,
...!openedWebAppsCount && {
sessionKeys: [],
},
},
}, tabId);
return global;
}
export function clearOpenedWebApps<T extends GlobalState>(
global: T,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
const webAppsNotAllowedToClose = Object.fromEntries(
Object.entries(currentTabState.webApps.openedWebApps).filter(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
([url, webApp]) => webApp.shouldConfirmClosing,
),
);
const webAppsNotAllowedToCloseValues = Object.values(webAppsNotAllowedToClose);
const hasNotAllowedToCloseApps = webAppsNotAllowedToCloseValues.length > 0;
if (!hasNotAllowedToCloseApps) {
return updateTabState(global, {
webApps: {
...currentTabState.webApps,
activeWebApp: undefined,
openedWebApps: {},
openedOrderedKeys: [],
sessionKeys: [],
},
}, tabId);
}
const currentActiveWebApp = currentTabState.webApps.activeWebApp;
const newActiveWebApp = currentActiveWebApp?.shouldConfirmClosing
? currentActiveWebApp : webAppsNotAllowedToCloseValues[0];
newActiveWebApp.isCloseModalOpen = true;
const key = getWebAppKey(newActiveWebApp);
if (key) webAppsNotAllowedToClose[key] = newActiveWebApp;
const newOpenedKeys = currentTabState.webApps.openedOrderedKeys.filter((k) => k in webAppsNotAllowedToClose);
return updateTabState(global, {
webApps: {
...currentTabState.webApps,
activeWebApp: newActiveWebApp,
openedWebApps: webAppsNotAllowedToClose,
openedOrderedKeys: newOpenedKeys,
},
}, tabId);
}
export function hasOpenedWebApps<T extends GlobalState>(
global: T, ...[tabId = getCurrentTabId()]: TabArgs<T>
): boolean {
return Object.keys(selectTabState(global, tabId).webApps.openedWebApps).length > 0;
}
export function replaceWebAppModalState<T extends GlobalState>(
global: T, modalState: WebAppModalStateType,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
return updateTabState(global, {
webApps: {
...currentTabState.webApps,
modalState,
},
}, tabId);
}
export function replaceIsWebAppModalOpen<T extends GlobalState>(
global: T, value: boolean,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const currentTabState = selectTabState(global, tabId);
return updateTabState(global, {
webApps: {
...currentTabState.webApps,
isModalOpen: value,
},
}, tabId);
}

View File

@ -133,6 +133,7 @@ import type {
ThemeKey,
ThreadId,
} from '../types';
import type { WebAppOutboundEvent } from '../types/webapp';
import type { SearchResultKey } from '../util/keys/searchResultKey';
import type { DownloadableMedia } from './helpers';
@ -143,6 +144,8 @@ export type MessageListType =
export type ChatListType = 'active' | 'archived' | 'saved';
export type WebAppModalStateType = 'maximized' | 'minimized';
export interface MessageList {
chatId: string;
threadId: ThreadId;
@ -649,14 +652,13 @@ export type TabState = {
isQuiz?: boolean;
};
webApp?: {
url: string;
botId: string;
buttonText: string;
queryId?: string;
slug?: string;
replyInfo?: ApiInputMessageReplyInfo;
canSendMessages?: boolean;
webApps: {
activeWebApp?: WebApp;
openedOrderedKeys: string[];
sessionKeys: string[];
openedWebApps: Record<string, WebApp>;
modalState : WebAppModalStateType;
isModalOpen: boolean;
};
botTrustRequest?: {
@ -1229,6 +1231,30 @@ export type ApiDraft = {
isLocal?: boolean;
};
export type WebApp = {
url: string;
requestUrl?: string;
botId: string;
appName?: string;
buttonText: string;
peerId?: string;
queryId?: string;
slug?: string;
replyInfo?: ApiInputMessageReplyInfo;
canSendMessages?: boolean;
isRemoveModalOpen?: boolean;
isCloseModalOpen?: boolean;
shouldConfirmClosing?: boolean;
headerColor?: string;
serverHeaderColor?: string;
serverHeaderColorKey?: 'bg_color' | 'secondary_bg_color';
backgroundColor?: string;
isBackButtonVisible?: boolean;
isSettingsButtonVisible?: boolean;
sendEvent?: (event: WebAppOutboundEvent) => void;
reloadFrame?: (url: string) => void;
};
type WithTabId = { tabId?: number };
export interface ActionPayloads {
@ -2032,7 +2058,6 @@ export interface ActionPayloads {
focusLastMessage: WithTabId | undefined;
updateDraftReplyInfo: Partial<ApiInputMessageReplyInfo> & WithTabId;
resetDraftReplyInfo: WithTabId | undefined;
closeWebApp: WithTabId | undefined;
// Multitab
destroyConnection: undefined;
@ -2930,6 +2955,9 @@ export interface ActionPayloads {
isFromBotMenu?: boolean;
startParam?: string;
} & WithTabId;
updateWebApp: {
webApp: Partial<WebApp>;
} & WithTabId;
requestMainWebView: {
botId: string;
peerId: string;
@ -2963,6 +2991,9 @@ export interface ActionPayloads {
isFromConfirm?: boolean;
shouldSkipBotTrustRequest?: boolean;
} & WithTabId;
openWebAppTab: {
webApp?: WebApp;
} & WithTabId;
loadPreviewMedias: {
botId: string;
};
@ -3066,6 +3097,13 @@ export interface ActionPayloads {
chatId: string;
usernames: string[];
};
closeActiveWebApp: WithTabId | undefined;
closeWebApp: {
webApp: WebApp;
skipClosingConfirmation?: boolean;
} & WithTabId;
closeWebAppModal: WithTabId | undefined;
changeWebAppModalState: WithTabId | undefined;
// Misc
refreshLangPackFromCache: {

232
src/hooks/useDraggable.ts Normal file
View File

@ -0,0 +1,232 @@
import type { RefObject } from 'react';
import { useEffect, useSignal, useState } from '../lib/teact/teact';
import buildStyle from '../util/buildStyle';
import { captureEvents } from '../util/captureEvents';
import useFlag from './useFlag';
import useLastCallback from './useLastCallback';
export interface Size {
width: number;
height: number;
}
export interface Point {
x: number;
y: number;
}
let resizeTimeout: number | undefined;
export default function useDraggable(
ref: RefObject<HTMLElement>,
dragHandleElementRef: RefObject<HTMLElement>,
isEnabled: boolean = true,
originalSize: Size,
) {
const [elementCurrentPosition, setElementCurrentPosition] = useState<Point | undefined>(undefined);
const [elementCurrentSize, setElementCurrentSize] = useState<Size | undefined>(undefined);
const [getElementPositionOnStartDrag, setElementPositionOnStartDrag] = useSignal({ x: 0, y: 0 });
const [getDragStartPoint, setDragStartPoint] = useSignal({ x: 0, y: 0 });
const elementPositionOnStartDrag = getElementPositionOnStartDrag();
const dragStartPoint = getDragStartPoint();
const element = ref.current;
const dragHandleElement = dragHandleElementRef.current;
const [isInitiated, setIsInitiated] = useFlag(false);
const [wasElementShown, setWasElementShown] = useFlag(false);
const [isDragging, startDragging, stopDragging] = useFlag(false);
const [isWindowsResizing, startWindowResizing, stopWindowResizing] = useFlag(false);
function getVisibleArea() {
return {
width: window.innerWidth,
height: window.innerHeight,
};
}
const getCenteredPosition = useLastCallback(() => {
if (!elementCurrentSize) return undefined;
const { width, height } = elementCurrentSize;
const visibleArea = getVisibleArea();
const viewportWidth = visibleArea.width;
const viewportHeight = visibleArea.height;
const centeredX = (viewportWidth - width) / 2;
const centeredY = (viewportHeight - height) / 2;
return { x: centeredX, y: centeredY };
});
useEffect(() => {
if (element) setWasElementShown();
}, [element]);
useEffect(() => {
if (!isInitiated && elementCurrentSize) {
const centeredPosition = getCenteredPosition();
if (!centeredPosition) return;
setElementCurrentPosition({ x: centeredPosition.x, y: centeredPosition.y });
setIsInitiated();
}
}, [elementCurrentSize, isInitiated, element]);
const handleStartDrag = useLastCallback((event: MouseEvent | TouchEvent) => {
const targetElement = event.target as HTMLElement;
if (targetElement.closest('.no-drag') || !element) {
return;
}
const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event;
const { left, top } = element.getBoundingClientRect();
setElementPositionOnStartDrag({ x: left, y: top });
setDragStartPoint({ x: pageX, y: pageY });
startDragging();
});
const handleRelease = useLastCallback(() => {
stopDragging();
});
useEffect(() => {
if (!isEnabled) {
stopDragging();
}
}, [isEnabled]);
const ensurePositionInVisibleArea = (x: number, y: number) => {
const visibleArea = getVisibleArea();
const visibleAreaWidth = visibleArea.width;
const visibleAreaHeight = visibleArea.height;
const componentWidth = elementCurrentSize!.width;
const componentHeight = elementCurrentSize!.height;
let newX = x;
let newY = y;
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX + componentWidth > visibleAreaWidth) newX = visibleAreaWidth - componentWidth;
if (newY + componentHeight > visibleAreaHeight) newY = visibleAreaHeight - componentHeight;
return { x: newX, y: newY };
};
const adjustPositionWithinBounds = useLastCallback(() => {
const position = !wasElementShown ? getCenteredPosition() : elementCurrentPosition;
if (!elementCurrentSize || !position) return;
const newPosition = ensurePositionInVisibleArea(position.x, position.y);
setElementCurrentPosition(newPosition);
});
const ensureSizeInVisibleArea = useLastCallback((sizeForCheck: Size) => {
const newSize = sizeForCheck;
const visibleArea = getVisibleArea();
newSize.width = Math.min(visibleArea.width, Math.max(originalSize.width, newSize.width));
newSize.height = Math.min(visibleArea.height, Math.max(originalSize.height, newSize.height));
return newSize;
});
useEffect(() => {
const newSize = ensureSizeInVisibleArea({ width: originalSize.width, height: originalSize.height });
if (newSize) setElementCurrentSize(newSize);
}, [originalSize]);
const adjustSizeWithinBounds = useLastCallback(() => {
if (!elementCurrentSize) return;
const newSize = ensureSizeInVisibleArea(elementCurrentSize);
if (newSize) setElementCurrentSize(newSize);
});
useEffect(() => {
adjustPositionWithinBounds();
}, [elementCurrentSize]);
useEffect(() => {
const handleResize = () => {
startWindowResizing();
adjustSizeWithinBounds();
adjustPositionWithinBounds();
if (resizeTimeout) {
clearTimeout(resizeTimeout);
resizeTimeout = undefined;
}
resizeTimeout = window.setTimeout(() => {
resizeTimeout = undefined;
stopWindowResizing();
}, 250);
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(resizeTimeout);
resizeTimeout = undefined;
window.removeEventListener('resize', handleResize);
};
}, [adjustPositionWithinBounds]);
const handleDrag = useLastCallback((event: MouseEvent | TouchEvent) => {
if (!isDragging || !element) return;
const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event;
const offsetX = pageX - dragStartPoint.x;
const offsetY = pageY - dragStartPoint.y;
const newX = elementPositionOnStartDrag.x + offsetX;
const newY = elementPositionOnStartDrag.y + offsetY;
if (elementCurrentSize) setElementCurrentPosition(ensurePositionInVisibleArea(newX, newY));
});
useEffect(() => {
let cleanup: NoneToVoidFunction | undefined;
if (dragHandleElement && isEnabled) {
cleanup = captureEvents(dragHandleElement, {
onCapture: handleStartDrag,
onDrag: handleDrag,
onRelease: handleRelease,
onClick: handleRelease,
onDoubleClick: handleRelease,
});
}
return cleanup;
}, [handleDrag, handleStartDrag, isEnabled, dragHandleElement]);
const cursorStyle = isDragging ? 'cursor: grabbing !important; ' : '';
if (!isInitiated || !elementCurrentSize || !elementCurrentPosition) {
return {
isDragging: false,
style: cursorStyle,
};
}
const style = buildStyle(
`left: ${elementCurrentPosition.x}px;`,
`top: ${elementCurrentPosition.y}px;`,
`width: ${elementCurrentSize.width}px;`,
`height: ${elementCurrentSize.height}px;`,
'position: fixed;',
(isDragging || isWindowsResizing) && 'transition: none !important;',
cursorStyle,
);
return {
position: elementCurrentPosition,
size: elementCurrentSize,
isDragging,
style,
};
}

View File

@ -29,6 +29,14 @@
mask-image: linear-gradient(to top, transparent $cutout, black $height);
}
@mixin gradient-border-horizontal($borderStart, $borderEnd) {
mask-image: linear-gradient(to right, transparent, black $borderStart, black $borderEnd, transparent);
}
@mixin gradient-border-left($indent) {
mask-image: linear-gradient(to right, transparent, black $indent);
}
@mixin gradient-border-top-bottom($top, $bottom) {
mask-image: linear-gradient(transparent 0%, black $top, black calc(100% - $bottom), transparent 100%);
}

View File

@ -79,198 +79,200 @@ $icons-map: (
"close-topic": "\f130",
"close": "\f131",
"cloud-download": "\f132",
"collapse": "\f133",
"colorize": "\f134",
"comments-sticker": "\f135",
"comments": "\f136",
"copy-media": "\f137",
"copy": "\f138",
"darkmode": "\f139",
"data": "\f13a",
"delete-filled": "\f13b",
"delete-left": "\f13c",
"delete-user": "\f13d",
"delete": "\f13e",
"document": "\f13f",
"double-badge": "\f140",
"down": "\f141",
"download": "\f142",
"eats": "\f143",
"edit": "\f144",
"email": "\f145",
"enter": "\f146",
"expand": "\f147",
"eye-closed-outline": "\f148",
"eye-closed": "\f149",
"eye-outline": "\f14a",
"eye": "\f14b",
"favorite-filled": "\f14c",
"favorite": "\f14d",
"file-badge": "\f14e",
"flag": "\f14f",
"folder-badge": "\f150",
"folder": "\f151",
"fontsize": "\f152",
"forums": "\f153",
"forward": "\f154",
"fullscreen": "\f155",
"gifs": "\f156",
"gift": "\f157",
"group-filled": "\f158",
"group": "\f159",
"grouped-disable": "\f15a",
"grouped": "\f15b",
"hand-stop": "\f15c",
"hashtag": "\f15d",
"heart-outline": "\f15e",
"heart": "\f15f",
"help": "\f160",
"info-filled": "\f161",
"info": "\f162",
"install": "\f163",
"italic": "\f164",
"key": "\f165",
"keyboard": "\f166",
"lamp": "\f167",
"language": "\f168",
"large-pause": "\f169",
"large-play": "\f16a",
"link-badge": "\f16b",
"link-broken": "\f16c",
"link": "\f16d",
"location": "\f16e",
"lock-badge": "\f16f",
"lock": "\f170",
"logout": "\f171",
"loop": "\f172",
"mention": "\f173",
"message-failed": "\f174",
"message-pending": "\f175",
"message-read": "\f176",
"message-succeeded": "\f177",
"message": "\f178",
"microphone-alt": "\f179",
"microphone": "\f17a",
"monospace": "\f17b",
"more-circle": "\f17c",
"more": "\f17d",
"move-caption-down": "\f17e",
"move-caption-up": "\f17f",
"mute": "\f180",
"muted": "\f181",
"my-notes": "\f182",
"new-chat-filled": "\f183",
"next": "\f184",
"nochannel": "\f185",
"noise-suppression": "\f186",
"non-contacts": "\f187",
"one-filled": "\f188",
"open-in-new-tab": "\f189",
"password-off": "\f18a",
"pause": "\f18b",
"permissions": "\f18c",
"phone-discard-outline": "\f18d",
"phone-discard": "\f18e",
"phone": "\f18f",
"photo": "\f190",
"pin-badge": "\f191",
"pin-list": "\f192",
"pin": "\f193",
"pinned-chat": "\f194",
"pinned-message": "\f195",
"pip": "\f196",
"play-story": "\f197",
"play": "\f198",
"poll": "\f199",
"previous": "\f19a",
"privacy-policy": "\f19b",
"quote-text": "\f19c",
"quote": "\f19d",
"readchats": "\f19e",
"recent": "\f19f",
"reload": "\f1a0",
"remove-quote": "\f1a1",
"remove": "\f1a2",
"reopen-topic": "\f1a3",
"replace": "\f1a4",
"replies": "\f1a5",
"reply-filled": "\f1a6",
"reply": "\f1a7",
"revenue-split": "\f1a8",
"revote": "\f1a9",
"save-story": "\f1aa",
"saved-messages": "\f1ab",
"schedule": "\f1ac",
"search": "\f1ad",
"select": "\f1ae",
"send-outline": "\f1af",
"send": "\f1b0",
"settings-filled": "\f1b1",
"settings": "\f1b2",
"share-filled": "\f1b3",
"share-screen-outlined": "\f1b4",
"share-screen-stop": "\f1b5",
"share-screen": "\f1b6",
"show-message": "\f1b7",
"sidebar": "\f1b8",
"skip-next": "\f1b9",
"skip-previous": "\f1ba",
"smallscreen": "\f1bb",
"smile": "\f1bc",
"sort": "\f1bd",
"speaker-muted-story": "\f1be",
"speaker-outline": "\f1bf",
"speaker-story": "\f1c0",
"speaker": "\f1c1",
"spoiler-disable": "\f1c2",
"spoiler": "\f1c3",
"sport": "\f1c4",
"star": "\f1c5",
"stars-lock": "\f1c6",
"stats": "\f1c7",
"stealth-future": "\f1c8",
"stealth-past": "\f1c9",
"stickers": "\f1ca",
"stop-raising-hand": "\f1cb",
"stop": "\f1cc",
"story-caption": "\f1cd",
"story-expired": "\f1ce",
"story-priority": "\f1cf",
"story-reply": "\f1d0",
"strikethrough": "\f1d1",
"tag-add": "\f1d2",
"tag-crossed": "\f1d3",
"tag-filter": "\f1d4",
"tag-name": "\f1d5",
"tag": "\f1d6",
"timer": "\f1d7",
"toncoin": "\f1d8",
"transcribe": "\f1d9",
"truck": "\f1da",
"unarchive": "\f1db",
"underlined": "\f1dc",
"unlock-badge": "\f1dd",
"unlock": "\f1de",
"unmute": "\f1df",
"unpin": "\f1e0",
"unread": "\f1e1",
"up": "\f1e2",
"user-filled": "\f1e3",
"user-online": "\f1e4",
"user": "\f1e5",
"video-outlined": "\f1e6",
"video-stop": "\f1e7",
"video": "\f1e8",
"view-once": "\f1e9",
"voice-chat": "\f1ea",
"volume-1": "\f1eb",
"volume-2": "\f1ec",
"volume-3": "\f1ed",
"web": "\f1ee",
"webapp": "\f1ef",
"word-wrap": "\f1f0",
"zoom-in": "\f1f1",
"zoom-out": "\f1f2",
"collapse-modal": "\f133",
"collapse": "\f134",
"colorize": "\f135",
"comments-sticker": "\f136",
"comments": "\f137",
"copy-media": "\f138",
"copy": "\f139",
"darkmode": "\f13a",
"data": "\f13b",
"delete-filled": "\f13c",
"delete-left": "\f13d",
"delete-user": "\f13e",
"delete": "\f13f",
"document": "\f140",
"double-badge": "\f141",
"down": "\f142",
"download": "\f143",
"eats": "\f144",
"edit": "\f145",
"email": "\f146",
"enter": "\f147",
"expand-modal": "\f148",
"expand": "\f149",
"eye-closed-outline": "\f14a",
"eye-closed": "\f14b",
"eye-outline": "\f14c",
"eye": "\f14d",
"favorite-filled": "\f14e",
"favorite": "\f14f",
"file-badge": "\f150",
"flag": "\f151",
"folder-badge": "\f152",
"folder": "\f153",
"fontsize": "\f154",
"forums": "\f155",
"forward": "\f156",
"fullscreen": "\f157",
"gifs": "\f158",
"gift": "\f159",
"group-filled": "\f15a",
"group": "\f15b",
"grouped-disable": "\f15c",
"grouped": "\f15d",
"hand-stop": "\f15e",
"hashtag": "\f15f",
"heart-outline": "\f160",
"heart": "\f161",
"help": "\f162",
"info-filled": "\f163",
"info": "\f164",
"install": "\f165",
"italic": "\f166",
"key": "\f167",
"keyboard": "\f168",
"lamp": "\f169",
"language": "\f16a",
"large-pause": "\f16b",
"large-play": "\f16c",
"link-badge": "\f16d",
"link-broken": "\f16e",
"link": "\f16f",
"location": "\f170",
"lock-badge": "\f171",
"lock": "\f172",
"logout": "\f173",
"loop": "\f174",
"mention": "\f175",
"message-failed": "\f176",
"message-pending": "\f177",
"message-read": "\f178",
"message-succeeded": "\f179",
"message": "\f17a",
"microphone-alt": "\f17b",
"microphone": "\f17c",
"monospace": "\f17d",
"more-circle": "\f17e",
"more": "\f17f",
"move-caption-down": "\f180",
"move-caption-up": "\f181",
"mute": "\f182",
"muted": "\f183",
"my-notes": "\f184",
"new-chat-filled": "\f185",
"next": "\f186",
"nochannel": "\f187",
"noise-suppression": "\f188",
"non-contacts": "\f189",
"one-filled": "\f18a",
"open-in-new-tab": "\f18b",
"password-off": "\f18c",
"pause": "\f18d",
"permissions": "\f18e",
"phone-discard-outline": "\f18f",
"phone-discard": "\f190",
"phone": "\f191",
"photo": "\f192",
"pin-badge": "\f193",
"pin-list": "\f194",
"pin": "\f195",
"pinned-chat": "\f196",
"pinned-message": "\f197",
"pip": "\f198",
"play-story": "\f199",
"play": "\f19a",
"poll": "\f19b",
"previous": "\f19c",
"privacy-policy": "\f19d",
"quote-text": "\f19e",
"quote": "\f19f",
"readchats": "\f1a0",
"recent": "\f1a1",
"reload": "\f1a2",
"remove-quote": "\f1a3",
"remove": "\f1a4",
"reopen-topic": "\f1a5",
"replace": "\f1a6",
"replies": "\f1a7",
"reply-filled": "\f1a8",
"reply": "\f1a9",
"revenue-split": "\f1aa",
"revote": "\f1ab",
"save-story": "\f1ac",
"saved-messages": "\f1ad",
"schedule": "\f1ae",
"search": "\f1af",
"select": "\f1b0",
"send-outline": "\f1b1",
"send": "\f1b2",
"settings-filled": "\f1b3",
"settings": "\f1b4",
"share-filled": "\f1b5",
"share-screen-outlined": "\f1b6",
"share-screen-stop": "\f1b7",
"share-screen": "\f1b8",
"show-message": "\f1b9",
"sidebar": "\f1ba",
"skip-next": "\f1bb",
"skip-previous": "\f1bc",
"smallscreen": "\f1bd",
"smile": "\f1be",
"sort": "\f1bf",
"speaker-muted-story": "\f1c0",
"speaker-outline": "\f1c1",
"speaker-story": "\f1c2",
"speaker": "\f1c3",
"spoiler-disable": "\f1c4",
"spoiler": "\f1c5",
"sport": "\f1c6",
"star": "\f1c7",
"stars-lock": "\f1c8",
"stats": "\f1c9",
"stealth-future": "\f1ca",
"stealth-past": "\f1cb",
"stickers": "\f1cc",
"stop-raising-hand": "\f1cd",
"stop": "\f1ce",
"story-caption": "\f1cf",
"story-expired": "\f1d0",
"story-priority": "\f1d1",
"story-reply": "\f1d2",
"strikethrough": "\f1d3",
"tag-add": "\f1d4",
"tag-crossed": "\f1d5",
"tag-filter": "\f1d6",
"tag-name": "\f1d7",
"tag": "\f1d8",
"timer": "\f1d9",
"toncoin": "\f1da",
"transcribe": "\f1db",
"truck": "\f1dc",
"unarchive": "\f1dd",
"underlined": "\f1de",
"unlock-badge": "\f1df",
"unlock": "\f1e0",
"unmute": "\f1e1",
"unpin": "\f1e2",
"unread": "\f1e3",
"up": "\f1e4",
"user-filled": "\f1e5",
"user-online": "\f1e6",
"user": "\f1e7",
"video-outlined": "\f1e8",
"video-stop": "\f1e9",
"video": "\f1ea",
"view-once": "\f1eb",
"voice-chat": "\f1ec",
"volume-1": "\f1ed",
"volume-2": "\f1ee",
"volume-3": "\f1ef",
"web": "\f1f0",
"webapp": "\f1f1",
"word-wrap": "\f1f2",
"zoom-in": "\f1f3",
"zoom-out": "\f1f4",
);
.icon-active-sessions::before {
@ -423,6 +425,9 @@ $icons-map: (
.icon-cloud-download::before {
content: map.get($icons-map, "cloud-download");
}
.icon-collapse-modal::before {
content: map.get($icons-map, "collapse-modal");
}
.icon-collapse::before {
content: map.get($icons-map, "collapse");
}
@ -483,6 +488,9 @@ $icons-map: (
.icon-enter::before {
content: map.get($icons-map, "enter");
}
.icon-expand-modal::before {
content: map.get($icons-map, "expand-modal");
}
.icon-expand::before {
content: map.get($icons-map, "expand");
}

Binary file not shown.

Binary file not shown.

View File

@ -6,6 +6,7 @@
"--color-primary-shade": ["#4a95d6", "#7b71c6"],
"--color-background": ["#FFFFFF", "#212121"],
"--color-background-compact-menu": ["#FFFFFFBB", "#212121DD"],
"--color-web-app-browser": ["#FFFFFFBB", "#0303038F"],
"--color-background-compact-menu-reactions": ["#FFFFFFEB", "#212121DD"],
"--color-background-compact-menu-hover": ["#00000011", "#00000066"],
"--color-background-secondary": ["#f4f4f5", "#0F0F0F"],

View File

@ -49,6 +49,7 @@ export type FontIconName =
| 'close-topic'
| 'close'
| 'cloud-download'
| 'collapse-modal'
| 'collapse'
| 'colorize'
| 'comments-sticker'
@ -69,6 +70,7 @@ export type FontIconName =
| 'edit'
| 'email'
| 'enter'
| 'expand-modal'
| 'expand'
| 'eye-closed-outline'
| 'eye-closed'

View File

@ -1539,6 +1539,9 @@ export interface LangPair {
'GiftStarsOutgoing': {
'user': string | number;
};
MiniAppsMoreTabs: {
'botName': string | number;
};
'PrizeCredits': {
'count': string | number;
};