1
0

expoui-tabs.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /**
  2. * System tab bar via expo-router NativeTabs (iOS UITabBar / Android Material 3).
  3. * Gated by `native.expo_ui_buttons` + `native.expo_native_tabs`.
  4. *
  5. * Expo UI TabView / NavigationBar are in-screen chrome, not a router. NEO's
  6. * tab0–tab4 slots are full-screen routes, so NativeTabs is the fit.
  7. */
  8. import { NativeTabs } from 'expo-router/unstable-native-tabs';
  9. import { useEffect, useMemo, useState } from 'react';
  10. import { PixelRatio, Platform, Pressable, useWindowDimensions } from 'react-native';
  11. import { Image } from 'expo-image';
  12. import { View } from 'app/design/view';
  13. import { androidTabBarHeight, appSetting, decodeTextNode, findIconFromRemote, isAndroid, isIos, IOS_TAB_BAR_MARGIN } from 'app/lib/util';
  14. import { useTranslation } from 'react-i18next';
  15. import { TabSlideHost } from 'app/components/nav/tab-slide';
  16. import { playTabFeedback } from 'app/components/nav/tab-feedback';
  17. import { joinUnaUrl } from 'app/config';
  18. import { ImageManipulator, SaveFormat } from 'app/lib/image/manipulator';
  19. import { pngBase64WithCircleMask } from 'app/lib/image/circle-png';
  20. import { getNativeOptimizedImageUrl } from 'app/lib/image-helpers';
  21. import { lucideAssetName, lucideDrawableName } from 'app/lib/platform/lucide-assets';
  22. import { getBadgeLabel, getOverflowBadgeTotal } from './badges';
  23. import {
  24. MORE_TAB_ICON,
  25. areTabBarLabelsHidden,
  26. buildTabBarMoreMenuItems,
  27. isDashboardTab,
  28. isProfileTab,
  29. splitProfileMoreMenu,
  30. splitTabBarItems,
  31. tabBarMoreSeparator,
  32. trimMenuSeparators,
  33. } from './tab-menu';
  34. import DropdownMenu from 'app/ui/atoms/dropdown-menu';
  35. import { NativeMoreSheet, hasNativeMoreSheet } from 'app/components/nav/tabs/more-sheet';
  36. import { OperatorAgentPanel, useOperatorAgentData } from 'app/ui/molecules/system/operator-agent';
  37. import { useTabBarColors } from './tab-colors';
  38. import { selectOverflowTab } from './tab-press';
  39. import { getWindowSafeAreaInsets, useGlobalSearchParams, useRouter, usePathname, useSafeAreaInsets } from 'app/lib/hooks/router';
  40. import { rememberSelectedTab } from 'app/lib/navigation/tab-history';
  41. import { useBottomSheetData } from 'app/context/bottomsheet';
  42. import { useCurrentUser } from 'app/context/user';
  43. const IOS_TAB_BAR_HEIGHT = 52;
  44. /** More popup entry that opens the operator agent (the iOS sheet shows an icon instead). */
  45. const OPERATOR_AGENT_ITEM_ID = 'operator-agent';
  46. function resolveTabIcons(icon) {
  47. const lucide = findIconFromRemote(icon) || icon;
  48. const mapped = appSetting('theme', 'expo_ui', 'tabs')?.icons?.[lucide];
  49. // Lucide mode: the generated template asset (iOS) / vector drawable
  50. // (Android), tinted by `iconColor` (no filled variant — selected is the
  51. // same glyph in `primary`).
  52. const xcasset = isIos && appSetting('theme', 'expo_ui', 'iosIconSource') !== 'sf'
  53. ? lucideAssetName(lucide)
  54. : null;
  55. const drawable = isAndroid && appSetting('theme', 'expo_ui', 'androidIconSource') !== 'material'
  56. ? lucideDrawableName(lucide)
  57. : null;
  58. if (xcasset) return { xcasset, md: mapped?.md || 'circle' };
  59. if (drawable) return { drawable };
  60. if (mapped) return mapped;
  61. const sfName = appSetting('theme', 'expo_ui', 'button')?.iosSymbols?.[lucide];
  62. if (typeof sfName === 'string' && sfName) {
  63. return { sf: { default: sfName, selected: `${sfName}.fill` }, md: 'circle' };
  64. }
  65. return { sf: 'circle', md: 'circle' };
  66. }
  67. /** `Icon` props for `resolveTabIcons()` — iOS priority is sf > xcasset, so send only one. */
  68. function tabIconProps(icons) {
  69. if (icons.drawable) return { drawable: icons.drawable };
  70. return icons.xcasset
  71. ? { xcasset: icons.xcasset, md: icons.md }
  72. : { sf: icons.sf, md: icons.md };
  73. }
  74. function getOverflowBadgeLabel(currentUser, overflow = []) {
  75. const total = getOverflowBadgeTotal(currentUser, overflow);
  76. if (total <= 0) return null;
  77. return total > 99 ? '99+' : String(total);
  78. }
  79. /** UITabBar draws `src` icons square at intrinsic size — resize + circle-mask once. */
  80. const TAB_AVATAR_PT = 28;
  81. /** Avatar in the More sheet's profile header. */
  82. const SHEET_AVATAR_PT = 44;
  83. const avatarIconCache = new Map();
  84. /** Absolute avatar URL, resized by the native images proxy when configured. */
  85. function avatarImageUri(avatar, pt) {
  86. const raw = String(avatar);
  87. const uri = raw.startsWith('data:') ? raw : joinUnaUrl(raw) || raw;
  88. return getNativeOptimizedImageUrl(uri, pt) || uri;
  89. }
  90. async function loadCircledTabAvatar(avatar) {
  91. const scale = PixelRatio.get();
  92. const px = Math.round(TAB_AVATAR_PT * scale);
  93. const remote = avatarImageUri(avatar, TAB_AVATAR_PT);
  94. await Image.prefetch(remote);
  95. const cached = await Image.getCachePathAsync(remote);
  96. const context = ImageManipulator.manipulate(cached ? `file://${cached}` : remote);
  97. context.resize({ width: px, height: px });
  98. const rendered = await context.renderAsync();
  99. const { base64 } = await rendered.saveAsync({ format: SaveFormat.PNG, compress: 1, base64: true });
  100. return {
  101. uri: `data:image/png;base64,${pngBase64WithCircleMask(base64)}`,
  102. width: TAB_AVATAR_PT,
  103. height: TAB_AVATAR_PT,
  104. scale,
  105. };
  106. }
  107. function useAvatarTabIcon(avatar) {
  108. const [source, setSource] = useState(() => (avatar ? avatarIconCache.get(avatar) ?? null : null));
  109. useEffect(() => {
  110. if (!avatar || avatarIconCache.has(avatar)) return undefined;
  111. let cancelled = false;
  112. loadCircledTabAvatar(avatar)
  113. .then((icon) => {
  114. avatarIconCache.set(avatar, icon);
  115. if (!cancelled) setSource(icon);
  116. })
  117. .catch(() => {});
  118. return () => {
  119. cancelled = true;
  120. };
  121. }, [avatar]);
  122. return source;
  123. }
  124. function renderTabTrigger({
  125. tab,
  126. index,
  127. currentUser,
  128. avatarSource,
  129. hideLabels,
  130. t,
  131. }) {
  132. const icons = resolveTabIcons(tab.icon);
  133. const badge = getBadgeLabel(currentUser, tab);
  134. return (
  135. <NativeTabs.Trigger
  136. key={`tab${index}`}
  137. name={`tab${index}`}
  138. hidden={tab.hide === true}
  139. disablePopToTop
  140. disableScrollToTop
  141. disableAutomaticContentInsets
  142. >
  143. {avatarSource ? (
  144. <NativeTabs.Trigger.Icon
  145. src={avatarSource}
  146. renderingMode="original"
  147. />
  148. ) : (
  149. <NativeTabs.Trigger.Icon {...tabIconProps(icons)} />
  150. )}
  151. {hideLabels || tab.title === '' ? (
  152. <NativeTabs.Trigger.Label hidden>
  153. {tab.title ? t(tab.title) : undefined}
  154. </NativeTabs.Trigger.Label>
  155. ) : (
  156. <NativeTabs.Trigger.Label>{t(tab.title)}</NativeTabs.Trigger.Label>
  157. )}
  158. {badge ? <NativeTabs.Trigger.Badge>{badge}</NativeTabs.Trigger.Badge> : null}
  159. </NativeTabs.Trigger>
  160. );
  161. }
  162. /**
  163. * iOS sheet: invisible target over the More (avatar) tab. The tab bar's glass
  164. * indicator starts sliding on touch-down, before iOS asks whether the tab may
  165. * be selected, so the menu tap is taken here and the tab bar never sees it —
  166. * the indicator and the page stay put and the sheet opens on touch-down.
  167. * Geometry of the iOS 26 floating bar on iPhone (measured on a 15 Pro): 21pt
  168. * side margins, 7.5pt inner padding, evenly spaced items, 49pt above the
  169. * bottom inset. Taps just outside it reach the `disabled` native tab, which
  170. * opens the menu too (with the indicator's bounce).
  171. */
  172. const IOS_TAB_BAR = { margin: IOS_TAB_BAR_MARGIN, padding: 7.5, height: 49 };
  173. function MoreTabTarget({ itemCount, onPress }) {
  174. const { width } = useWindowDimensions();
  175. const insets = useSafeAreaInsets();
  176. const slot = (width - 2 * (IOS_TAB_BAR.margin + IOS_TAB_BAR.padding)) / itemCount;
  177. return (
  178. <Pressable
  179. accessible={false}
  180. importantForAccessibility="no-hide-descendants"
  181. onPressIn={onPress}
  182. style={{
  183. position: 'absolute',
  184. right: 0,
  185. bottom: 0,
  186. width: IOS_TAB_BAR.margin + IOS_TAB_BAR.padding + slot,
  187. height: IOS_TAB_BAR.height + insets.bottom,
  188. zIndex: 9999,
  189. }}
  190. />
  191. );
  192. }
  193. /**
  194. * Android: the bottom bar tints every tab icon with one color list, so the
  195. * avatar can't be a native tab icon (it would come out as a flat tinted disc).
  196. * It is drawn over its tab instead, centered on the Material 3 icon slot —
  197. * 28dp below the bar's top edge, with or without labels. With `onPress` the
  198. * whole tab column is the More target (the native tab is `disabled` while it
  199. * opens the sheet); without, touches go through to the native tab.
  200. */
  201. const ANDROID_TAB_ICON_CENTER = 28;
  202. function AndroidTabOverlay({ slotIndex, slotCount, barHeight, avatar, onPress, accessibilityLabel }) {
  203. const { width } = useWindowDimensions();
  204. const slot = width / slotCount;
  205. return (
  206. <Pressable
  207. onPress={onPress}
  208. disabled={!onPress}
  209. pointerEvents={onPress ? 'auto' : 'none'}
  210. accessibilityRole={onPress ? 'button' : undefined}
  211. accessibilityLabel={onPress ? accessibilityLabel : undefined}
  212. style={{
  213. position: 'absolute',
  214. bottom: 0,
  215. left: slotIndex * slot,
  216. width: slot,
  217. height: barHeight,
  218. zIndex: 9999,
  219. elevation: 24,
  220. }}
  221. >
  222. {avatar ? (
  223. <Image
  224. source={{ uri: avatar }}
  225. alt=""
  226. contentFit="cover"
  227. style={{
  228. position: 'absolute',
  229. top: ANDROID_TAB_ICON_CENTER - TAB_AVATAR_PT / 2,
  230. left: (slot - TAB_AVATAR_PT) / 2,
  231. width: TAB_AVATAR_PT,
  232. height: TAB_AVATAR_PT,
  233. borderRadius: TAB_AVATAR_PT / 2,
  234. }}
  235. />
  236. ) : null}
  237. </Pressable>
  238. );
  239. }
  240. /** Transparent hit target over the native More tab — opens the same popup as JS tabs (without a native sheet). */
  241. function NativeTabsMoreOverlay({
  242. visible,
  243. isShowTabs,
  244. overflowMenuItems,
  245. onSelectOverflow,
  246. moreMenuOpen,
  247. onMoreMenuOpenChange,
  248. onMoreTriggerPress,
  249. tabBarHeight,
  250. }) {
  251. const { t } = useTranslation();
  252. if (!isShowTabs || !overflowMenuItems?.length) return null;
  253. return (
  254. <View
  255. pointerEvents="box-none"
  256. collapsable={false}
  257. style={{
  258. position: 'absolute',
  259. left: 0,
  260. right: 0,
  261. bottom: 0,
  262. height: tabBarHeight,
  263. zIndex: 9999,
  264. ...(isAndroid ? { elevation: 24 } : null),
  265. }}
  266. >
  267. <View pointerEvents="box-none" className="h-full flex-row">
  268. {visible.map((tab, index) => (
  269. <View key={tab.key || `spacer-${index}`} className="flex-1" pointerEvents="none" />
  270. ))}
  271. <View className="flex-1" collapsable={false}>
  272. <DropdownMenu
  273. mode="popup"
  274. showOnTop
  275. openOnFocus={false}
  276. open={moreMenuOpen}
  277. onOpenChange={onMoreMenuOpenChange}
  278. items={overflowMenuItems}
  279. onSelect={onSelectOverflow}
  280. triggerAccessibilityLabel={t('More')}
  281. triggerClassName="flex-1 w-full h-full"
  282. >
  283. <View
  284. className="flex-1 w-full h-full"
  285. onTouchEnd={() => onMoreTriggerPress()}
  286. />
  287. </DropdownMenu>
  288. </View>
  289. </View>
  290. </View>
  291. );
  292. }
  293. export default function ExpoUITabNavigator({
  294. TabList,
  295. currentUser,
  296. colors,
  297. isShowTabs,
  298. tabsSessionKey,
  299. }) {
  300. const { t } = useTranslation();
  301. const pathname = usePathname();
  302. const router = useRouter();
  303. const insets = useSafeAreaInsets();
  304. const { setCurrentUser } = useCurrentUser();
  305. const { setBottomSheetData } = useBottomSheetData();
  306. const hideLabels = areTabBarLabelsHidden(TabList);
  307. const avatarIcon = useAvatarTabIcon(isIos ? currentUser?.avatar : null);
  308. // Own avatar as a tab icon: the `{profile}` item, or the dashboard in menus without one.
  309. const hasProfileTab = TabList.some(isProfileTab);
  310. const usesAvatarIcon = (tab) => (hasProfileTab ? isProfileTab(tab) : isDashboardTab(tab, currentUser));
  311. const { visible, overflow, moreTabIndex } = useMemo(
  312. () => splitTabBarItems(TabList),
  313. [TabList]
  314. );
  315. // Overflow page the More tab shows (sheet highlight). It outlives visits to
  316. // other tabs — the More tab comes back on it — and resets with the session.
  317. const [activeOverflow, setActiveOverflow] = useState({ session: tabsSessionKey, url: null });
  318. const activeOverflowUrl = activeOverflow.session === tabsSessionKey ? activeOverflow.url : null;
  319. const [moreMenuOpen, setMoreMenuOpen] = useState(false);
  320. // Operator agent: opened from the More menu (same rule as pages skipping
  321. // their floating button, `hasNativeTabsMoreMenu`) and shown over every tab.
  322. const agentData = useOperatorAgentData(overflow.length > 0 && !!isShowTabs);
  323. const agentParams = useGlobalSearchParams();
  324. const [agentOpen, setAgentOpen] = useState(false);
  325. const toggleAgent = () => {
  326. setMoreMenuOpen(false);
  327. setAgentOpen((open) => !open);
  328. };
  329. const { selectedInk, selectedIndicator, badgeBackground } = useTabBarColors(colors);
  330. // JS popup's hit area over the More tab; the Android bar pads itself with the system inset.
  331. const tabBarHeight = isAndroid ? androidTabBarHeight(hideLabels) + insets.bottom : IOS_TAB_BAR_HEIGHT;
  332. // Agent card sits above the bar, where the page float used to.
  333. const agentBottomOffset = 16 + (isAndroid
  334. ? tabBarHeight
  335. : IOS_TAB_BAR.height + (getWindowSafeAreaInsets().bottom || 0));
  336. const overflowMenuItems = useMemo(
  337. () => buildTabBarMoreMenuItems({
  338. visible,
  339. overflow,
  340. moreTabIndex,
  341. currentUser,
  342. t,
  343. pathname,
  344. activeOverflowUrl,
  345. mode: 'native',
  346. }),
  347. [visible, overflow, moreTabIndex, currentUser, t, pathname, activeOverflowUrl]
  348. );
  349. // Plain function — React Compiler memoizes it (manual useCallback here
  350. // could not be preserved by the compiler).
  351. const handleOverflowSelect = async (item) => {
  352. if (item?.id === OPERATOR_AGENT_ITEM_ID) {
  353. toggleAgent();
  354. return;
  355. }
  356. const tabUrl = await selectOverflowTab({
  357. item,
  358. currentUser,
  359. setCurrentUser,
  360. setBottomSheetData,
  361. router,
  362. moreTabIndex,
  363. });
  364. if (!tabUrl) return;
  365. setMoreMenuOpen(false);
  366. if (item.isOverflow) setActiveOverflow({ session: tabsSessionKey, url: tabUrl });
  367. };
  368. const collapsed = overflow.length > 0;
  369. const moreRoot = overflow[0];
  370. // A `{profile}` item heading the More list makes the More tab the user's
  371. // avatar and the sheet's header (Linear style); web's footer does the same.
  372. const profileMore = collapsed && isProfileTab(moreRoot);
  373. const sheetExclude = appSetting('theme', 'expo_ui', 'tabs')?.more_sheet_exclude || [];
  374. const profileMenu = splitProfileMoreMenu({
  375. items: overflowMenuItems,
  376. overflow,
  377. hasAgent: !!agentData,
  378. exclude: sheetExclude,
  379. });
  380. const sheetHeader = profileMore && hasNativeMoreSheet ? profileMenu.header : null;
  381. const sheetItems = sheetHeader
  382. ? profileMenu.items
  383. : trimMenuSeparators(overflowMenuItems.filter((item) => !sheetExclude.includes(item.tab?.url)));
  384. // The profile is the only More link left: the avatar opens it directly
  385. // (unless the sheet is also where the operator agent opens).
  386. const profileOnly = !!sheetHeader && profileMenu.profileOnly;
  387. // Popup More menu (Android, iOS without the sheet): Agent heads the list,
  388. // shown selected while its chat is open.
  389. const popupMenuItems = agentData
  390. ? [
  391. { id: OPERATOR_AGENT_ITEM_ID, title: t('operator_agent_title'), icon: 'Sparkles', selected: agentOpen },
  392. { ...tabBarMoreSeparator(), id: `${OPERATOR_AGENT_ITEM_ID}-separator` },
  393. ...overflowMenuItems,
  394. ]
  395. : overflowMenuItems;
  396. // The menu opens over the current page: `MoreTabTarget` takes the tap on
  397. // iPhone, and the `disabled` native tab covers the rest (edge taps,
  398. // VoiceOver, iPad). With the profile as the only More link the avatar is a
  399. // real tab instead — the press selects it and the profile shows.
  400. // Plain values, like the handlers above — React Compiler memoizes them.
  401. const handleMoreTabPress = () => {
  402. if (profileOnly) {
  403. rememberSelectedTab(`/tab${moreTabIndex}`);
  404. return;
  405. }
  406. playTabFeedback();
  407. setMoreMenuOpen(true);
  408. };
  409. // More presses go to the trigger's own `listeners`; every tab press gets the click.
  410. const moreTabListeners = { tabPress: handleMoreTabPress };
  411. const screenListeners = { tabPress: () => playTabFeedback() };
  412. const moreIcons = resolveTabIcons(profileMore ? moreRoot.icon : MORE_TAB_ICON);
  413. const moreTitle = profileMore ? t(moreRoot.title) : t('More');
  414. const overflowBadge = getOverflowBadgeLabel(currentUser, overflow);
  415. // Android: avatar and More target drawn over the bar (see AndroidTabOverlay).
  416. // Slots are the tabs the bar shows, plus More.
  417. const barTabs = (collapsed ? visible : TabList).filter((tab) => tab.hide !== true);
  418. const slotCount = barTabs.length + (collapsed ? 1 : 0);
  419. const androidAvatar = isAndroid && currentUser?.avatar ? avatarImageUri(currentUser.avatar, TAB_AVATAR_PT) : null;
  420. const avatarSlot = collapsed && profileMore ? barTabs.length : barTabs.findIndex(usesAvatarIcon);
  421. const androidMoreTarget = isAndroid && isShowTabs && collapsed && hasNativeMoreSheet && !profileOnly;
  422. return (
  423. <View className="flex-1">
  424. <NativeTabs
  425. key={tabsSessionKey}
  426. hidden={!isShowTabs}
  427. tintColor={selectedInk}
  428. backgroundColor={isAndroid ? colors.barsBackground : undefined}
  429. indicatorColor={isAndroid ? selectedIndicator : undefined}
  430. rippleColor={isAndroid ? colors.primary : undefined}
  431. badgeBackgroundColor={badgeBackground}
  432. iconColor={{
  433. default: colors.barsColor,
  434. selected: selectedInk,
  435. }}
  436. labelStyle={{
  437. default: { color: colors.barsColor },
  438. selected: { color: selectedInk },
  439. }}
  440. labelVisibilityMode={hideLabels ? 'unlabeled' : 'labeled'}
  441. tabBarRespectsIMEInsets={isAndroid}
  442. screenListeners={screenListeners}
  443. unstable_nativeProps={{
  444. nativeContainerStyle: {
  445. backgroundColor: isIos ? 'transparent' : colors.barsBackground,
  446. },
  447. }}
  448. >
  449. {collapsed
  450. ? visible.map((tab, index) => renderTabTrigger({
  451. tab,
  452. index,
  453. currentUser,
  454. avatarSource: usesAvatarIcon(tab) ? avatarIcon : null,
  455. hideLabels,
  456. t,
  457. }))
  458. : TabList.map((tab, index) => renderTabTrigger({
  459. tab,
  460. index,
  461. currentUser,
  462. avatarSource: usesAvatarIcon(tab) ? avatarIcon : null,
  463. hideLabels,
  464. t,
  465. }))}
  466. {collapsed ? (
  467. // `disabled` (native preventNativeSelection) while More opens a
  468. // menu: a tap only emits `tabPress` and never selects the tab.
  469. // A normal tab when the profile is its only link. Router
  470. // navigation (overflow picks, links) lands on it either way.
  471. <NativeTabs.Trigger
  472. key={`tab${moreTabIndex}`}
  473. name={`tab${moreTabIndex}`}
  474. disabled={!profileOnly}
  475. disablePopToTop
  476. disableScrollToTop
  477. disableAutomaticContentInsets
  478. listeners={moreTabListeners}
  479. >
  480. {profileMore && avatarIcon ? (
  481. <NativeTabs.Trigger.Icon src={avatarIcon} renderingMode="original" />
  482. ) : (
  483. <NativeTabs.Trigger.Icon {...tabIconProps(moreIcons)} />
  484. )}
  485. {hideLabels ? (
  486. <NativeTabs.Trigger.Label hidden>{moreTitle}</NativeTabs.Trigger.Label>
  487. ) : (
  488. <NativeTabs.Trigger.Label>{moreTitle}</NativeTabs.Trigger.Label>
  489. )}
  490. {overflowBadge ? (
  491. <NativeTabs.Trigger.Badge>{overflowBadge}</NativeTabs.Trigger.Badge>
  492. ) : null}
  493. </NativeTabs.Trigger>
  494. ) : null}
  495. </NativeTabs>
  496. {isIos && collapsed && hasNativeMoreSheet && isShowTabs && !profileOnly && !Platform.isPad ? (
  497. <MoreTabTarget
  498. itemCount={visible.filter((tab) => tab.hide !== true).length + 1}
  499. onPress={handleMoreTabPress}
  500. />
  501. ) : null}
  502. {collapsed && hasNativeMoreSheet && isShowTabs ? (
  503. <NativeMoreSheet
  504. open={moreMenuOpen}
  505. onOpenChange={setMoreMenuOpen}
  506. items={sheetItems}
  507. onSelect={handleOverflowSelect}
  508. profile={sheetHeader ? {
  509. item: sheetHeader,
  510. // SwiftUI / Compose text: decode UNA's escaped name here.
  511. name: decodeTextNode(currentUser?.display_name),
  512. avatar: currentUser?.avatar ? avatarImageUri(currentUser.avatar, SHEET_AVATAR_PT) : null,
  513. } : null}
  514. onAgentPress={agentData ? toggleAgent : undefined}
  515. agentActive={agentOpen}
  516. />
  517. ) : null}
  518. {androidMoreTarget ? (
  519. <AndroidTabOverlay
  520. slotIndex={barTabs.length}
  521. slotCount={slotCount}
  522. barHeight={tabBarHeight}
  523. avatar={profileMore ? androidAvatar : null}
  524. onPress={handleMoreTabPress}
  525. accessibilityLabel={moreTitle}
  526. />
  527. ) : null}
  528. {isAndroid && isShowTabs && androidAvatar && avatarSlot >= 0 && !(androidMoreTarget && avatarSlot === barTabs.length) ? (
  529. <AndroidTabOverlay
  530. slotIndex={avatarSlot}
  531. slotCount={slotCount}
  532. barHeight={tabBarHeight}
  533. avatar={androidAvatar}
  534. />
  535. ) : null}
  536. {collapsed && !hasNativeMoreSheet ? (
  537. <NativeTabsMoreOverlay
  538. visible={visible}
  539. isShowTabs={isShowTabs}
  540. overflowMenuItems={popupMenuItems}
  541. onSelectOverflow={handleOverflowSelect}
  542. moreMenuOpen={moreMenuOpen}
  543. onMoreMenuOpenChange={setMoreMenuOpen}
  544. onMoreTriggerPress={handleMoreTabPress}
  545. tabBarHeight={tabBarHeight}
  546. />
  547. ) : null}
  548. <TabSlideHost
  549. sessionKey={tabsSessionKey}
  550. backgroundColor={colors.background}
  551. />
  552. {agentData ? (
  553. <OperatorAgentPanel
  554. data={agentData}
  555. open={agentOpen}
  556. onClose={() => setAgentOpen(false)}
  557. bottomOffset={agentBottomOffset}
  558. params={agentParams}
  559. />
  560. ) : null}
  561. </View>
  562. );
  563. }