MiniApps: More apps tab (#5137)

This commit is contained in:
Alexander Zinchuk 2024-11-09 15:40:37 +04:00
parent c0be156229
commit 92ebef4f49
16 changed files with 468 additions and 30 deletions

View File

@ -1370,6 +1370,10 @@
"StarsSubscribeInfoLinkText" = "Terms of Service";
"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars";
"StarsPerMonth" = "⭐️{amount}/month";
"OpenApp" = "Open App";
"PopularApps" = "Popular Apps";
"SearchApps" = "Search Apps";
"Apps" = "Apps";
"AreYouSureCloseMiniApps" = "Are you sure you want to close all Mini Apps?";
"CloseMiniApps" = "Close Mini Apps";
"DoNotAskAgain" = "Don't ask again";

View File

@ -18,6 +18,8 @@ type OwnProps = {
badgeIcon?: IconName;
className?: string;
badgeClassName?: string;
badgeIconClassName?: string;
textClassName?: string;
onClick?: NoneToVoidFunction;
};
@ -28,6 +30,8 @@ const PeerBadge = ({
badgeIcon,
className,
badgeClassName,
badgeIconClassName,
textClassName,
onClick,
}: OwnProps) => {
return (
@ -39,12 +43,12 @@ const PeerBadge = ({
<Avatar size="large" peer={peer} />
{badgeText && (
<div className={buildClassName(styles.badge, badgeClassName)}>
{badgeIcon && <Icon name={badgeIcon} />}
{badgeIcon && <Icon name={badgeIcon} className={badgeIconClassName} />}
{badgeText}
</div>
)}
</div>
{text && <p className={styles.text}>{text}</p>}
{text && <p className={buildClassName(styles.text, textClassName)}>{text}</p>}
</div>
);
};

View File

@ -207,12 +207,14 @@
.Link {
float: right;
color: var(--color-links);
font-weight: 400;
font-weight: 500;
margin-right: 1.5rem;
transition: opacity 0.15s ease-in;
&:focus,
&:active,
&:hover {
text-decoration: underline;
text-decoration: none;
opacity: 0.85;
}
}

View File

@ -67,7 +67,7 @@ const MinimizedWebAppModal = ({
if (!isMinimizedState) return undefined;
function renderTitle() {
const activeTabName = activeTabBot?.firstName;
const activeTabName = peers.length > 0 && peers[0]?.firstName;
const title = openedTabsCount && activeTabName && openedTabsCount > 1
? `${lang('MiniAppsMoreTabs',
{

View File

@ -0,0 +1,49 @@
@use "../../../styles/mixins";
.root {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
z-index: 0;
padding-inline: 1.25rem;
padding-top: 1.25rem;
overflow-y: scroll;
@include mixins.adapt-padding-to-scrollbar(1rem);
}
.search {
margin-bottom: 1rem;
}
.section-title {
font-size: 1rem;
font-weight: 500;
padding: 0.5rem;
padding-block: 0.25rem;
justify-content: space-between;
display: flex;
padding-bottom: 0.5rem;
}
.section-content {
display: grid;
justify-content: center;
align-items: center;
gap: 0.125rem;
grid-template-columns: repeat(5, 1fr);
padding: 0.125rem;
padding-bottom: 1.25em;
}
.showMoreLink {
color: var(--color-links);
cursor: pointer;
transition: opacity 0.2s ease-in;
&:hover, &:active {
background-color: transparent !important;
opacity: 0.85;
}
}

View File

@ -0,0 +1,143 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo,
useCallback,
useMemo,
useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import { LoadMoreDirection } from '../../../types';
import { filterUsersByName } from '../../../global/helpers';
import { selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { throttle } from '../../../util/schedulers';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import InfiniteScroll from '../../ui/InfiniteScroll';
import SearchInput from '../../ui/SearchInput';
import WebAppGridItem from './WebAppGridtem';
import styles from './MoreAppsTabContent.module.scss';
const POPULAR_APPS_SLICE = 30;
export type OwnProps = {
};
type StateProps = {
isLoading?: boolean;
foundIds?: string[];
recentBotIds?: string[];
};
const LESS_GRID_ITEMS_AMOUNT = 5;
const runThrottled = throttle((cb) => cb(), 500, true);
const MoreAppsTabContent: FC<OwnProps & StateProps> = ({
foundIds,
recentBotIds,
}) => {
const oldLang = useOldLang();
const lang = useLang();
const [shouldShowMoreMine, setShouldShowMoreMine] = useState<boolean>(false);
const {
searchPopularBotApps,
} = getActions();
const handleToggleShowMoreMine = useLastCallback(() => {
setShouldShowMoreMine((prev) => !prev);
});
const [searchQuery, setSearchQuery] = useState('');
const filteredFoundIds = useMemo(() => {
if (!foundIds) return [];
const usersById = getGlobal().users.byId;
return filterUsersByName(foundIds, usersById, searchQuery);
}, [foundIds, searchQuery]);
const handleLoadMore = useCallback(({ direction }: { direction: LoadMoreDirection }) => {
if (direction === LoadMoreDirection.Backwards) {
runThrottled(() => {
searchPopularBotApps();
});
}
}, []);
const handleSearchInputReset = useCallback(() => {
setSearchQuery('');
}, []);
return (
<InfiniteScroll
className={buildClassName(styles.root, 'custom-scroll')}
items={filteredFoundIds}
onLoadMore={handleLoadMore}
itemSelector=".PopularAppGridItem"
noFastList
preloadBackwards={POPULAR_APPS_SLICE}
>
<SearchInput
className={styles.search}
value={searchQuery}
onChange={setSearchQuery}
onReset={handleSearchInputReset}
placeholder={lang('SearchApps')}
/>
{recentBotIds && !searchQuery && (
<div className={styles.section}>
<div className={styles.sectionTitle}>
<span>{oldLang('SearchAppsMine')}</span>
<span className={styles.showMoreLink} onClick={handleToggleShowMoreMine}>
{oldLang(shouldShowMoreMine ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')}
</span>
</div>
<div className={styles.sectionContent}>
{recentBotIds.map((id, index) => {
if (!shouldShowMoreMine && index >= LESS_GRID_ITEMS_AMOUNT) {
return undefined;
}
return (
<WebAppGridItem
chatId={id}
/>
);
})}
</div>
</div>
)}
<div className={styles.section}>
<div className={styles.sectionTitle}>
{searchQuery ? lang('Apps') : lang('PopularApps')}
</div>
<div className={styles.sectionContent}>
{filteredFoundIds && filteredFoundIds.map((id) => {
return (
<WebAppGridItem
chatId={id}
isPopularApp={!searchQuery}
/>
);
})}
</div>
</div>
</InfiniteScroll>
);
};
export default memo(withGlobal<OwnProps>((global) => {
const globalSearch = selectTabState(global).globalSearch;
const foundIds = globalSearch.popularBotApps?.peerIds;
return {
isLoading: !foundIds && globalSearch.fetchingStatus?.botApps,
foundIds,
recentBotIds: global.topBotApps.userIds,
};
})(MoreAppsTabContent));

View File

@ -0,0 +1,39 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.375rem;
border-radius: 0.625rem;
cursor: var(--custom-cursor, pointer);
&:hover {
background-color: var(--color-background-secondary);
}
}
.user-count-badge {
font-family: var(--font-family-condensed);
font-weight: 600 !important;
font-size: 0.5rem !important;
border-width: 1px !important;
bottom: 0 !important;
padding-block: 0.1875rem !important;
background: rgba(0, 0, 0, 0.2) !important;
backdrop-filter: blur(50px);
}
.user-badge-icon {
font-size: 0.4375rem !important;
}
.name {
white-space: inherit !important;
max-width: 100% !important;
width: 4rem !important;
font-size: 0.625rem !important;
height: 1.625rem;
font-weight: 500;
line-height: 0.75rem;
}

View File

@ -0,0 +1,87 @@
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiUser,
} from '../../../api/types';
import {
selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatIntegerCompact } from '../../../util/textFormat';
import { extractCurrentThemeParams } from '../../../util/themeStyle';
import useLastCallback from '../../../hooks/useLastCallback';
import PeerBadge from '../../common/PeerBadge';
import styles from './WebAppGridItem.module.scss';
export type OwnProps = {
// eslint-disable-next-line react/no-unused-prop-types
chatId: string;
isPopularApp?: boolean;
};
export type StateProps = {
user?: ApiUser;
};
function WebAppGridItem({ user, isPopularApp }: OwnProps & StateProps) {
const {
requestMainWebView,
} = getActions();
const handleClick = useLastCallback(() => {
if (!user) {
return;
}
const botId = user?.id;
if (!botId) {
return;
}
const theme = extractCurrentThemeParams();
requestMainWebView({
botId,
peerId: botId,
theme,
});
});
if (!user) return undefined;
// eslint-disable-next-line no-null/no-null
const title = user?.firstName;
const activeUserCount = user?.botActiveUsers;
const badgeText = activeUserCount && isPopularApp ? formatIntegerCompact(activeUserCount) : undefined;
return (
<div
className={styles.container}
onClick={handleClick}
>
<PeerBadge
className={buildClassName(styles.avatarContainer, isPopularApp && 'PopularAppGridItem')}
textClassName={styles.name}
badgeClassName={styles.userCountBadge}
badgeIconClassName={styles.userBadgeIcon}
peer={user}
text={title}
badgeText={badgeText}
badgeIcon="user-filled"
/>
</div>
);
}
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const user = selectUser(global, chatId);
return {
user,
};
},
)(WebAppGridItem));

View File

@ -161,7 +161,7 @@
padding-left: 1rem;
padding-right: 1rem;
font-size: 0.875rem;
font-size: 1rem;
font-weight: 500;
text-overflow: ellipsis;
@ -220,6 +220,7 @@
transform: scaleX(-1);
}
.more-apps-tab-icon,
.avatar-container {
position: relative;
display: flex;
@ -227,6 +228,11 @@
margin-right: 0.5rem;
}
.more-apps-tab-icon {
font-size: 1.5rem;
color: var(--color-text-secondary);
}
.web-app-tab-more-menu {
z-index: 1;
position: absolute;
@ -255,6 +261,7 @@
transform: translate(-50%, -50%);
}
.more-apps-button,
.window-state-button,
.header-button {
width: 1.75rem !important;

View File

@ -26,6 +26,7 @@ import useAppLayout from '../../../hooks/useAppLayout';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useDraggable from '../../../hooks/useDraggable';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
@ -37,6 +38,7 @@ import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import Modal from '../../ui/Modal';
import MinimizedWebAppModal from './MinimizedWebAppModal';
import MoreAppsTabContent from './MoreAppsTabContent';
import WebAppModalTabContent from './WebAppModalTabContent';
import styles from './WebAppModal.module.scss';
@ -77,6 +79,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
changeWebAppModalState,
openWebAppTab,
updateWebApp,
openMoreAppsTab,
closeMoreAppsTab,
} = getActions();
const maximizedStateSize = useMemo(() => {
@ -94,7 +98,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
}
const {
openedWebApps, activeWebApp, openedOrderedKeys, sessionKeys,
openedWebApps, activeWebApp, openedOrderedKeys, sessionKeys, isMoreAppsTabActive,
} = modal || {};
const {
isBackButtonVisible, headerColor, backgroundColor, isSettingsButtonVisible,
@ -168,7 +172,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
}
}, [currentWidth, currentHeight, isMaximizedState, minimizedStateSize, setFrameSize]);
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
const {
queryId,
} = activeWebApp || {};
@ -229,6 +234,10 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
closeWebAppModal();
});
const handleCloseMoreAppsTab = useLastCallback(() => {
closeMoreAppsTab();
});
const handleTabClose = useLastCallback(() => {
if (openTabsCount > 1) {
closeActiveWebApp();
@ -268,6 +277,10 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
changeWebAppModalState();
});
const handleOpenMoreAppsTabClick = useLastCallback(() => {
openMoreAppsTab();
});
const handleTabClick = useLastCallback((tab: WebAppModalTab) => {
openWebAppTab({ webApp: tab.webApp });
});
@ -304,12 +317,12 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
return (
<>
{chat && bot && chat.id !== bot.id && (
<MenuItem icon="bots" onClick={openBotChat}>{lang('BotWebViewOpenBot')}</MenuItem>
<MenuItem icon="bots" onClick={openBotChat}>{oldLang('BotWebViewOpenBot')}</MenuItem>
)}
<MenuItem icon="reload" onClick={handleRefreshClick}>{lang('WebApp.ReloadPage')}</MenuItem>
<MenuItem icon="reload" onClick={handleRefreshClick}>{oldLang('WebApp.ReloadPage')}</MenuItem>
{isSettingsButtonVisible && (
<MenuItem icon="settings" onClick={handleSettingsButtonClick}>
{lang('Settings')}
{oldLang('Settings')}
</MenuItem>
)}
{bot?.isAttachBot && (
@ -318,7 +331,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
onClick={handleToggleClick}
destructive={Boolean(attachBot)}
>
{lang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')}
{oldLang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')}
</MenuItem>
)}
</>
@ -368,12 +381,13 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
);
const headerTextVar = useMemo(() => {
if (isMoreAppsTabActive) return 'color-text';
if (!headerColor) return undefined;
const { r, g, b } = hexToRgb(headerColor);
const luma = getColorLuma([r, g, b]);
const adaptedLuma = theme === 'dark' ? 255 - luma : luma;
return adaptedLuma > LUMA_THRESHOLD ? 'color-text' : 'color-background';
}, [headerColor, theme]);
}, [headerColor, theme, isMoreAppsTabActive]);
function renderTabCurveBorder(className: string) {
return (
@ -422,7 +436,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
round
color="translucent"
size="tiny"
ariaLabel={lang('Close')}
ariaLabel={oldLang('Close')}
onClick={handleTabClose}
>
<Icon className={styles.tabCloseIcon} name="close" />
@ -433,6 +447,53 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
);
}
function renderMoreAppsTab() {
return (
<div
className={styles.tabButtonWrapper}
>
{renderTabCurveBorder(styles.tabButtonLeftCurve)}
<div
className={styles.tabButton}
>
<div className={styles.moreAppsTabIcon}>
<Icon className={styles.icon} name="add" />
</div>
{lang('OpenApp')}
<div className={styles.tabRightMask} />
<Button
className={styles.tabCloseButton}
round
color="translucent"
size="tiny"
ariaLabel={oldLang('Close')}
onClick={handleCloseMoreAppsTab}
>
<Icon className={styles.tabCloseIcon} name="close" />
</Button>
</div>
{renderTabCurveBorder(styles.tabButtonRightCurve)}
</div>
);
}
function renderMoreAppsButton() {
return (
<Button
className={buildClassName(
styles.moreAppsButton,
'no-drag',
)}
round
color="translucent"
size="tiny"
onClick={handleOpenMoreAppsTabClick}
>
<Icon className={styles.icon} name="add" />
</Button>
);
}
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
useHorizontalScroll(containerRef, !isOpen || !isMaximizedState || !(containerRef.current));
@ -456,6 +517,8 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
/>
)
))}
{isMoreAppsTabActive && renderMoreAppsTab()}
{!isMoreAppsTabActive && renderMoreAppsButton()}
</div>
);
}
@ -488,7 +551,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
round
color="translucent"
size="tiny"
ariaLabel={lang(isBackButtonVisible ? 'Back' : 'Close')}
ariaLabel={oldLang(isBackButtonVisible ? 'Back' : 'Close')}
onClick={handleBackClick}
>
<div className={backButtonClassName} />
@ -496,15 +559,6 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
{renderTabs()}
{renderMoreMenu()}
{/* <Button
round
color="translucent"
size="tiny"
>
<Icon className={styles.icon} name="add" />
</Button>
*/}
<Button
className={buildClassName(
styles.windowStateButton,
@ -534,13 +588,13 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
round
color="translucent"
size="smaller"
ariaLabel={lang(isBackButtonVisible ? 'Back' : 'Close')}
ariaLabel={oldLang(isBackButtonVisible ? 'Back' : 'Close')}
onClick={handleBackClick}
>
<div className={backButtonClassName} />
</Button>
<div className="modal-title">{attachBot?.shortName ?? bot?.firstName}</div>
{renderDropdownMoreMenu()}
{!isMoreAppsTabActive && renderDropdownMoreMenu()}
</div>
);
}
@ -574,6 +628,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
isMultiTabSupported={supportMultiTabMode}
/>
))}
{ isMoreAppsTabActive && (<MoreAppsTabContent />)}
</Modal>
);
};

View File

@ -888,6 +888,43 @@ addActionHandler('closeActiveWebApp', (global, actions, payload): ActionReturnTy
return global;
});
addActionHandler('openMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
webApps: {
...tabState.webApps,
activeWebApp: undefined,
isMoreAppsTabActive: true,
},
}, tabId);
return global;
});
addActionHandler('closeMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const openedWebApps = tabState.webApps.openedWebApps;
const openedWebAppsValues = Object.values(openedWebApps);
const openedWebAppsCount = openedWebAppsValues.length;
global = updateTabState(global, {
webApps: {
...tabState.webApps,
isMoreAppsTabActive: false,
activeWebApp: openedWebAppsCount ? openedWebAppsValues[openedWebAppsCount - 1] : undefined,
isModalOpen: openedWebAppsCount > 0,
},
}, tabId);
return global;
});
addActionHandler('closeWebApp', (global, actions, payload): ActionReturnType => {
const { webApp, skipClosingConfirmation, tabId = getCurrentTabId() } = payload || {};

View File

@ -346,6 +346,7 @@ export const INITIAL_TAB_STATE: TabState = {
sessionKeys: [],
modalState: 'maximized',
isModalOpen: false,
isMoreAppsTabActive: false,
},
globalSearch: {},

View File

@ -90,6 +90,7 @@ export function activateWebAppIfOpen<T extends GlobalState>(
global = updateTabState(global, {
webApps: {
...currentTabState.webApps,
isMoreAppsTabActive: false,
activeWebApp: newActiveWebApp,
modalState: 'maximized',
},
@ -120,6 +121,7 @@ export function addWebAppToOpenList<T extends GlobalState>(
webApps: {
...currentTabState.webApps,
...makeActive && { activeWebApp: webApp },
isMoreAppsTabActive: false,
isModalOpen: openModalIfNotOpen,
modalState: 'maximized',
openedWebApps: {
@ -234,6 +236,7 @@ export function clearOpenedWebApps<T extends GlobalState>(
webApps: {
...currentTabState.webApps,
activeWebApp: newActiveWebApp,
isMoreAppsTabActive: false,
openedWebApps: webAppsNotAllowedToClose,
openedOrderedKeys: newOpenedKeys,
},

View File

@ -697,6 +697,7 @@ export type TabState = {
openedWebApps: Record<string, WebApp>;
modalState : WebAppModalStateType;
isModalOpen: boolean;
isMoreAppsTabActive: boolean;
};
botTrustRequest?: {
@ -3241,6 +3242,8 @@ export interface ActionPayloads {
usernames: string[];
};
closeActiveWebApp: WithTabId | undefined;
openMoreAppsTab: WithTabId | undefined;
closeMoreAppsTab: WithTabId | undefined;
closeWebApp: {
webApp: WebApp;
skipClosingConfirmation?: boolean;

View File

@ -1,15 +1,15 @@
import { useEffect, useState } from '../lib/teact/teact';
import type { ApiChat } from '../api/types';
import type { ApiPeer } from '../api/types';
import { ApiMediaFormat } from '../api/types';
import { getChatAvatarHash } from '../global/helpers';
import { getAverageColor, rgb2hex } from '../util/colors';
import useMedia from './useMedia';
function useAverageColor(chat: ApiChat, fallbackColor = '#00000000') {
function useAverageColor(peer: ApiPeer, fallbackColor = '#00000000') {
const [color, setColor] = useState(fallbackColor);
const imgBlobUrl = useMedia(getChatAvatarHash(chat), false, ApiMediaFormat.BlobUrl);
const imgBlobUrl = useMedia(getChatAvatarHash(peer), false, ApiMediaFormat.BlobUrl);
useEffect(() => {
(async () => {

View File

@ -1141,6 +1141,10 @@ export interface LangPair {
'StarGiftAvailability': undefined;
'StarsSubscribeInfoLinkText': undefined;
'StarsSubscribeInfoLink': undefined;
'OpenApp': undefined;
'PopularApps': undefined;
'SearchApps': undefined;
'Apps': undefined;
'AreYouSureCloseMiniApps': undefined;
'CloseMiniApps': undefined;
'DoNotAskAgain': undefined;