From 92ebef4f4919e086ca78bc95744f55da0c702901 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 9 Nov 2024 15:40:37 +0400 Subject: [PATCH] MiniApps: More apps tab (#5137) --- src/assets/localization/fallback.strings | 4 + src/components/common/PeerBadge.tsx | 8 +- src/components/left/search/LeftSearch.scss | 8 +- .../modals/webApp/MinimizedWebAppModal.tsx | 2 +- .../webApp/MoreAppsTabContent.module.scss | 49 ++++++ .../modals/webApp/MoreAppsTabContent.tsx | 143 ++++++++++++++++++ .../modals/webApp/WebAppGridItem.module.scss | 39 +++++ .../modals/webApp/WebAppGridtem.tsx | 87 +++++++++++ .../modals/webApp/WebAppModal.module.scss | 9 +- src/components/modals/webApp/WebAppModal.tsx | 95 +++++++++--- src/global/actions/api/bots.ts | 37 +++++ src/global/initialState.ts | 1 + src/global/reducers/bots.ts | 3 + src/global/types.ts | 3 + src/hooks/useAverageColor.ts | 6 +- src/types/language.d.ts | 4 + 16 files changed, 468 insertions(+), 30 deletions(-) create mode 100644 src/components/modals/webApp/MoreAppsTabContent.module.scss create mode 100644 src/components/modals/webApp/MoreAppsTabContent.tsx create mode 100644 src/components/modals/webApp/WebAppGridItem.module.scss create mode 100644 src/components/modals/webApp/WebAppGridtem.tsx diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index d54464dbd..79a877f21 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; diff --git a/src/components/common/PeerBadge.tsx b/src/components/common/PeerBadge.tsx index fd91ca7ef..a6868833b 100644 --- a/src/components/common/PeerBadge.tsx +++ b/src/components/common/PeerBadge.tsx @@ -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 = ({ {badgeText && (
- {badgeIcon && } + {badgeIcon && } {badgeText}
)} - {text &&

{text}

} + {text &&

{text}

} ); }; diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index 790f6951f..9041e2476 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -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; } } diff --git a/src/components/modals/webApp/MinimizedWebAppModal.tsx b/src/components/modals/webApp/MinimizedWebAppModal.tsx index 05bafb9d9..104f5bd95 100644 --- a/src/components/modals/webApp/MinimizedWebAppModal.tsx +++ b/src/components/modals/webApp/MinimizedWebAppModal.tsx @@ -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', { diff --git a/src/components/modals/webApp/MoreAppsTabContent.module.scss b/src/components/modals/webApp/MoreAppsTabContent.module.scss new file mode 100644 index 000000000..001186c56 --- /dev/null +++ b/src/components/modals/webApp/MoreAppsTabContent.module.scss @@ -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; + } +} diff --git a/src/components/modals/webApp/MoreAppsTabContent.tsx b/src/components/modals/webApp/MoreAppsTabContent.tsx new file mode 100644 index 000000000..b34681374 --- /dev/null +++ b/src/components/modals/webApp/MoreAppsTabContent.tsx @@ -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 = ({ + foundIds, + recentBotIds, +}) => { + const oldLang = useOldLang(); + const lang = useLang(); + const [shouldShowMoreMine, setShouldShowMoreMine] = useState(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 ( + + + {recentBotIds && !searchQuery && ( +
+
+ {oldLang('SearchAppsMine')} + + {oldLang(shouldShowMoreMine ? 'ChatList.Search.ShowLess' : 'ChatList.Search.ShowMore')} + +
+
+ {recentBotIds.map((id, index) => { + if (!shouldShowMoreMine && index >= LESS_GRID_ITEMS_AMOUNT) { + return undefined; + } + + return ( + + ); + })} +
+
+ )} +
+
+ {searchQuery ? lang('Apps') : lang('PopularApps')} +
+
+ {filteredFoundIds && filteredFoundIds.map((id) => { + return ( + + ); + })} +
+
+
+ ); +}; + +export default memo(withGlobal((global) => { + const globalSearch = selectTabState(global).globalSearch; + const foundIds = globalSearch.popularBotApps?.peerIds; + + return { + isLoading: !foundIds && globalSearch.fetchingStatus?.botApps, + foundIds, + recentBotIds: global.topBotApps.userIds, + }; +})(MoreAppsTabContent)); diff --git a/src/components/modals/webApp/WebAppGridItem.module.scss b/src/components/modals/webApp/WebAppGridItem.module.scss new file mode 100644 index 000000000..46da71fa3 --- /dev/null +++ b/src/components/modals/webApp/WebAppGridItem.module.scss @@ -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; +} diff --git a/src/components/modals/webApp/WebAppGridtem.tsx b/src/components/modals/webApp/WebAppGridtem.tsx new file mode 100644 index 000000000..33dd431c7 --- /dev/null +++ b/src/components/modals/webApp/WebAppGridtem.tsx @@ -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 ( +
+ +
+ ); +} + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const user = selectUser(global, chatId); + + return { + user, + }; + }, +)(WebAppGridItem)); diff --git a/src/components/modals/webApp/WebAppModal.module.scss b/src/components/modals/webApp/WebAppModal.module.scss index 28b53cfe3..5652b0d12 100644 --- a/src/components/modals/webApp/WebAppModal.module.scss +++ b/src/components/modals/webApp/WebAppModal.module.scss @@ -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; diff --git a/src/components/modals/webApp/WebAppModal.tsx b/src/components/modals/webApp/WebAppModal.tsx index 275cd86e8..5a12fbce2 100644 --- a/src/components/modals/webApp/WebAppModal.tsx +++ b/src/components/modals/webApp/WebAppModal.tsx @@ -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 = ({ changeWebAppModalState, openWebAppTab, updateWebApp, + openMoreAppsTab, + closeMoreAppsTab, } = getActions(); const maximizedStateSize = useMemo(() => { @@ -94,7 +98,7 @@ const WebAppModal: FC = ({ } const { - openedWebApps, activeWebApp, openedOrderedKeys, sessionKeys, + openedWebApps, activeWebApp, openedOrderedKeys, sessionKeys, isMoreAppsTabActive, } = modal || {}; const { isBackButtonVisible, headerColor, backgroundColor, isSettingsButtonVisible, @@ -168,7 +172,8 @@ const WebAppModal: FC = ({ } }, [currentWidth, currentHeight, isMaximizedState, minimizedStateSize, setFrameSize]); - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); const { queryId, } = activeWebApp || {}; @@ -229,6 +234,10 @@ const WebAppModal: FC = ({ closeWebAppModal(); }); + const handleCloseMoreAppsTab = useLastCallback(() => { + closeMoreAppsTab(); + }); + const handleTabClose = useLastCallback(() => { if (openTabsCount > 1) { closeActiveWebApp(); @@ -268,6 +277,10 @@ const WebAppModal: FC = ({ changeWebAppModalState(); }); + const handleOpenMoreAppsTabClick = useLastCallback(() => { + openMoreAppsTab(); + }); + const handleTabClick = useLastCallback((tab: WebAppModalTab) => { openWebAppTab({ webApp: tab.webApp }); }); @@ -304,12 +317,12 @@ const WebAppModal: FC = ({ return ( <> {chat && bot && chat.id !== bot.id && ( - {lang('BotWebViewOpenBot')} + {oldLang('BotWebViewOpenBot')} )} - {lang('WebApp.ReloadPage')} + {oldLang('WebApp.ReloadPage')} {isSettingsButtonVisible && ( - {lang('Settings')} + {oldLang('Settings')} )} {bot?.isAttachBot && ( @@ -318,7 +331,7 @@ const WebAppModal: FC = ({ onClick={handleToggleClick} destructive={Boolean(attachBot)} > - {lang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} + {oldLang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} )} @@ -368,12 +381,13 @@ const WebAppModal: FC = ({ ); 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 = ({ round color="translucent" size="tiny" - ariaLabel={lang('Close')} + ariaLabel={oldLang('Close')} onClick={handleTabClose} > @@ -433,6 +447,53 @@ const WebAppModal: FC = ({ ); } + function renderMoreAppsTab() { + return ( +
+ {renderTabCurveBorder(styles.tabButtonLeftCurve)} +
+
+ +
+ {lang('OpenApp')} +
+ +
+ {renderTabCurveBorder(styles.tabButtonRightCurve)} +
+ ); + } + + function renderMoreAppsButton() { + return ( + + ); + } + // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); useHorizontalScroll(containerRef, !isOpen || !isMaximizedState || !(containerRef.current)); @@ -456,6 +517,8 @@ const WebAppModal: FC = ({ /> ) ))} + {isMoreAppsTabActive && renderMoreAppsTab()} + {!isMoreAppsTabActive && renderMoreAppsButton()}
); } @@ -488,7 +551,7 @@ const WebAppModal: FC = ({ round color="translucent" size="tiny" - ariaLabel={lang(isBackButtonVisible ? 'Back' : 'Close')} + ariaLabel={oldLang(isBackButtonVisible ? 'Back' : 'Close')} onClick={handleBackClick} >
@@ -496,15 +559,6 @@ const WebAppModal: FC = ({ {renderTabs()} {renderMoreMenu()} - {/* - */} -
{attachBot?.shortName ?? bot?.firstName}
- {renderDropdownMoreMenu()} + {!isMoreAppsTabActive && renderDropdownMoreMenu()}
); } @@ -574,6 +628,7 @@ const WebAppModal: FC = ({ isMultiTabSupported={supportMultiTabMode} /> ))} + { isMoreAppsTabActive && ()} ); }; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index aed18b1d2..763d03ede 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -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 || {}; diff --git a/src/global/initialState.ts b/src/global/initialState.ts index d31a21fd8..b0bca22d2 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -346,6 +346,7 @@ export const INITIAL_TAB_STATE: TabState = { sessionKeys: [], modalState: 'maximized', isModalOpen: false, + isMoreAppsTabActive: false, }, globalSearch: {}, diff --git a/src/global/reducers/bots.ts b/src/global/reducers/bots.ts index c417d112b..f310b1804 100644 --- a/src/global/reducers/bots.ts +++ b/src/global/reducers/bots.ts @@ -90,6 +90,7 @@ export function activateWebAppIfOpen( global = updateTabState(global, { webApps: { ...currentTabState.webApps, + isMoreAppsTabActive: false, activeWebApp: newActiveWebApp, modalState: 'maximized', }, @@ -120,6 +121,7 @@ export function addWebAppToOpenList( webApps: { ...currentTabState.webApps, ...makeActive && { activeWebApp: webApp }, + isMoreAppsTabActive: false, isModalOpen: openModalIfNotOpen, modalState: 'maximized', openedWebApps: { @@ -234,6 +236,7 @@ export function clearOpenedWebApps( webApps: { ...currentTabState.webApps, activeWebApp: newActiveWebApp, + isMoreAppsTabActive: false, openedWebApps: webAppsNotAllowedToClose, openedOrderedKeys: newOpenedKeys, }, diff --git a/src/global/types.ts b/src/global/types.ts index 4f2435d18..412b0ba4d 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -697,6 +697,7 @@ export type TabState = { openedWebApps: Record; 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; diff --git a/src/hooks/useAverageColor.ts b/src/hooks/useAverageColor.ts index d9b92de13..dc03145bc 100644 --- a/src/hooks/useAverageColor.ts +++ b/src/hooks/useAverageColor.ts @@ -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 () => { diff --git a/src/types/language.d.ts b/src/types/language.d.ts index f21eb0a31..fb7a1c151 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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;