{children}
diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts
index a1c4db8b2..eccae4e40 100644
--- a/src/global/actions/api/bots.ts
+++ b/src/global/actions/api/bots.ts
@@ -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
{
+ 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 => {
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 {
+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(
+ global: T, webApp: Partial, 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(
+ 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(global: T, hash?: string) {
const result = await callApi('loadAttachBots', { hash });
if (!result) {
diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts
index 8b4979a1b..2b5a8cc68 100644
--- a/src/global/actions/apiUpdaters/misc.ts
+++ b/src/global/actions/apiUpdaters/misc.ts
@@ -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;
diff --git a/src/global/actions/ui/bots.ts b/src/global/actions/ui/bots.ts
new file mode 100644
index 000000000..f85f89eab
--- /dev/null
+++ b/src/global/actions/ui/bots.ts
@@ -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);
+});
diff --git a/src/global/helpers/bots.ts b/src/global/helpers/bots.ts
index 5c5a8bebf..85f57721e 100644
--- a/src/global/helpers/bots.ts
+++ b/src/global/helpers/bots.ts
@@ -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) {
+ if (webApp.requestUrl) return webApp.requestUrl;
+ if (webApp.appName) return `${webApp.botId}?appName=${webApp.appName}`;
+ return webApp.botId;
+}
diff --git a/src/global/initialState.ts b/src/global/initialState.ts
index 40f1a0878..9a5d5e05a 100644
--- a/src/global/initialState.ts
+++ b/src/global/initialState.ts
@@ -325,6 +325,14 @@ export const INITIAL_TAB_STATE: TabState = {
byUsername: {},
},
+ webApps: {
+ openedWebApps: {},
+ openedOrderedKeys: [],
+ sessionKeys: [],
+ modalState: 'maximized',
+ isModalOpen: false,
+ },
+
globalSearch: {},
userSearch: {},
diff --git a/src/global/reducers/bots.ts b/src/global/reducers/bots.ts
index efaa815ee..d9fb68ca5 100644
--- a/src/global/reducers/bots.ts
+++ b/src/global/reducers/bots.ts
@@ -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(
},
}, tabId);
}
+
+export function updateWebApp (
+ global: T, webApp: Partial,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): 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(
+ global: T, webAppKey: string,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): 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(
+ global: T, webApp: WebApp,
+ makeActive: boolean = true, openModalIfNotOpen: boolean = true,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): 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(
+ global: T, ...[tabId = getCurrentTabId()]: TabArgs
+): T {
+ const currentTabState = selectTabState(global, tabId);
+
+ if (!currentTabState.webApps.activeWebApp) return global;
+
+ return removeWebAppFromOpenList(global, currentTabState.webApps.activeWebApp, false, tabId);
+}
+
+export function removeWebAppFromOpenList(
+ global: T, webApp: WebApp, skipClosingConfirmation?: boolean,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): 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(
+ global: T,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): 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(
+ global: T, ...[tabId = getCurrentTabId()]: TabArgs
+): boolean {
+ return Object.keys(selectTabState(global, tabId).webApps.openedWebApps).length > 0;
+}
+
+export function replaceWebAppModalState(
+ global: T, modalState: WebAppModalStateType,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): T {
+ const currentTabState = selectTabState(global, tabId);
+ return updateTabState(global, {
+ webApps: {
+ ...currentTabState.webApps,
+ modalState,
+ },
+ }, tabId);
+}
+
+export function replaceIsWebAppModalOpen(
+ global: T, value: boolean,
+ ...[tabId = getCurrentTabId()]: TabArgs
+): T {
+ const currentTabState = selectTabState(global, tabId);
+ return updateTabState(global, {
+ webApps: {
+ ...currentTabState.webApps,
+ isModalOpen: value,
+ },
+ }, tabId);
+}
diff --git a/src/global/types.ts b/src/global/types.ts
index e9dbc1e53..8ca1ed6e2 100644
--- a/src/global/types.ts
+++ b/src/global/types.ts
@@ -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;
+ 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 & 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;
+ } & 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: {
diff --git a/src/hooks/useDraggable.ts b/src/hooks/useDraggable.ts
new file mode 100644
index 000000000..1171e6c78
--- /dev/null
+++ b/src/hooks/useDraggable.ts
@@ -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,
+ dragHandleElementRef: RefObject,
+ isEnabled: boolean = true,
+ originalSize: Size,
+) {
+ const [elementCurrentPosition, setElementCurrentPosition] = useState(undefined);
+ const [elementCurrentSize, setElementCurrentSize] = useState(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,
+ };
+}
diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss
index 2d696cc77..b226562c3 100644
--- a/src/styles/_mixins.scss
+++ b/src/styles/_mixins.scss
@@ -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%);
}
diff --git a/src/styles/icons.scss b/src/styles/icons.scss
index 2db14044e..ff690b158 100644
--- a/src/styles/icons.scss
+++ b/src/styles/icons.scss
@@ -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");
}
diff --git a/src/styles/icons.woff b/src/styles/icons.woff
index 0dde4c5c1..333d75af5 100644
Binary files a/src/styles/icons.woff and b/src/styles/icons.woff differ
diff --git a/src/styles/icons.woff2 b/src/styles/icons.woff2
index fdd2a37cb..944c72776 100644
Binary files a/src/styles/icons.woff2 and b/src/styles/icons.woff2 differ
diff --git a/src/styles/themes.json b/src/styles/themes.json
index b64b8aa0f..b0062b628 100644
--- a/src/styles/themes.json
+++ b/src/styles/themes.json
@@ -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"],
diff --git a/src/types/icons/font.ts b/src/types/icons/font.ts
index dec68b118..ad792e226 100644
--- a/src/types/icons/font.ts
+++ b/src/types/icons/font.ts
@@ -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'
diff --git a/src/types/language.d.ts b/src/types/language.d.ts
index 8f04f95d6..3e83acdfc 100644
--- a/src/types/language.d.ts
+++ b/src/types/language.d.ts
@@ -1539,6 +1539,9 @@ export interface LangPair {
'GiftStarsOutgoing': {
'user': string | number;
};
+ MiniAppsMoreTabs: {
+ 'botName': string | number;
+ };
'PrizeCredits': {
'count': string | number;
};