/** * System tab bar via expo-router NativeTabs (iOS UITabBar / Android Material 3). * Gated by `native.expo_ui_buttons` + `native.expo_native_tabs`. * * Expo UI TabView / NavigationBar are in-screen chrome, not a router. NEO's * tab0–tab4 slots are full-screen routes, so NativeTabs is the fit. */ import { NativeTabs } from 'expo-router/unstable-native-tabs'; import { useEffect, useMemo, useState } from 'react'; import { PixelRatio, Platform, Pressable, useWindowDimensions } from 'react-native'; import { Image } from 'expo-image'; import { View } from 'app/design/view'; import { androidTabBarHeight, appSetting, decodeTextNode, findIconFromRemote, isAndroid, isIos, IOS_TAB_BAR_MARGIN } from 'app/lib/util'; import { useTranslation } from 'react-i18next'; import { TabSlideHost } from 'app/components/nav/tab-slide'; import { playTabFeedback } from 'app/components/nav/tab-feedback'; import { joinUnaUrl } from 'app/config'; import { ImageManipulator, SaveFormat } from 'app/lib/image/manipulator'; import { pngBase64WithCircleMask } from 'app/lib/image/circle-png'; import { getNativeOptimizedImageUrl } from 'app/lib/image-helpers'; import { lucideAssetName, lucideDrawableName } from 'app/lib/platform/lucide-assets'; import { getBadgeLabel, getOverflowBadgeTotal } from './badges'; import { MORE_TAB_ICON, areTabBarLabelsHidden, buildTabBarMoreMenuItems, isDashboardTab, isProfileTab, splitProfileMoreMenu, splitTabBarItems, tabBarMoreSeparator, trimMenuSeparators, } from './tab-menu'; import DropdownMenu from 'app/ui/atoms/dropdown-menu'; import { NativeMoreSheet, hasNativeMoreSheet } from 'app/components/nav/tabs/more-sheet'; import { OperatorAgentPanel, useOperatorAgentData } from 'app/ui/molecules/system/operator-agent'; import { useTabBarColors } from './tab-colors'; import { selectOverflowTab } from './tab-press'; import { getWindowSafeAreaInsets, useGlobalSearchParams, useRouter, usePathname, useSafeAreaInsets } from 'app/lib/hooks/router'; import { rememberSelectedTab } from 'app/lib/navigation/tab-history'; import { useBottomSheetData } from 'app/context/bottomsheet'; import { useCurrentUser } from 'app/context/user'; const IOS_TAB_BAR_HEIGHT = 52; /** More popup entry that opens the operator agent (the iOS sheet shows an icon instead). */ const OPERATOR_AGENT_ITEM_ID = 'operator-agent'; function resolveTabIcons(icon) { const lucide = findIconFromRemote(icon) || icon; const mapped = appSetting('theme', 'expo_ui', 'tabs')?.icons?.[lucide]; // Lucide mode: the generated template asset (iOS) / vector drawable // (Android), tinted by `iconColor` (no filled variant — selected is the // same glyph in `primary`). const xcasset = isIos && appSetting('theme', 'expo_ui', 'iosIconSource') !== 'sf' ? lucideAssetName(lucide) : null; const drawable = isAndroid && appSetting('theme', 'expo_ui', 'androidIconSource') !== 'material' ? lucideDrawableName(lucide) : null; if (xcasset) return { xcasset, md: mapped?.md || 'circle' }; if (drawable) return { drawable }; if (mapped) return mapped; const sfName = appSetting('theme', 'expo_ui', 'button')?.iosSymbols?.[lucide]; if (typeof sfName === 'string' && sfName) { return { sf: { default: sfName, selected: `${sfName}.fill` }, md: 'circle' }; } return { sf: 'circle', md: 'circle' }; } /** `Icon` props for `resolveTabIcons()` — iOS priority is sf > xcasset, so send only one. */ function tabIconProps(icons) { if (icons.drawable) return { drawable: icons.drawable }; return icons.xcasset ? { xcasset: icons.xcasset, md: icons.md } : { sf: icons.sf, md: icons.md }; } function getOverflowBadgeLabel(currentUser, overflow = []) { const total = getOverflowBadgeTotal(currentUser, overflow); if (total <= 0) return null; return total > 99 ? '99+' : String(total); } /** UITabBar draws `src` icons square at intrinsic size — resize + circle-mask once. */ const TAB_AVATAR_PT = 28; /** Avatar in the More sheet's profile header. */ const SHEET_AVATAR_PT = 44; const avatarIconCache = new Map(); /** Absolute avatar URL, resized by the native images proxy when configured. */ function avatarImageUri(avatar, pt) { const raw = String(avatar); const uri = raw.startsWith('data:') ? raw : joinUnaUrl(raw) || raw; return getNativeOptimizedImageUrl(uri, pt) || uri; } async function loadCircledTabAvatar(avatar) { const scale = PixelRatio.get(); const px = Math.round(TAB_AVATAR_PT * scale); const remote = avatarImageUri(avatar, TAB_AVATAR_PT); await Image.prefetch(remote); const cached = await Image.getCachePathAsync(remote); const context = ImageManipulator.manipulate(cached ? `file://${cached}` : remote); context.resize({ width: px, height: px }); const rendered = await context.renderAsync(); const { base64 } = await rendered.saveAsync({ format: SaveFormat.PNG, compress: 1, base64: true }); return { uri: `data:image/png;base64,${pngBase64WithCircleMask(base64)}`, width: TAB_AVATAR_PT, height: TAB_AVATAR_PT, scale, }; } function useAvatarTabIcon(avatar) { const [source, setSource] = useState(() => (avatar ? avatarIconCache.get(avatar) ?? null : null)); useEffect(() => { if (!avatar || avatarIconCache.has(avatar)) return undefined; let cancelled = false; loadCircledTabAvatar(avatar) .then((icon) => { avatarIconCache.set(avatar, icon); if (!cancelled) setSource(icon); }) .catch(() => {}); return () => { cancelled = true; }; }, [avatar]); return source; } function renderTabTrigger({ tab, index, currentUser, avatarSource, hideLabels, t, }) { const icons = resolveTabIcons(tab.icon); const badge = getBadgeLabel(currentUser, tab); return ( ); } /** * iOS sheet: invisible target over the More (avatar) tab. The tab bar's glass * indicator starts sliding on touch-down, before iOS asks whether the tab may * be selected, so the menu tap is taken here and the tab bar never sees it — * the indicator and the page stay put and the sheet opens on touch-down. * Geometry of the iOS 26 floating bar on iPhone (measured on a 15 Pro): 21pt * side margins, 7.5pt inner padding, evenly spaced items, 49pt above the * bottom inset. Taps just outside it reach the `disabled` native tab, which * opens the menu too (with the indicator's bounce). */ const IOS_TAB_BAR = { margin: IOS_TAB_BAR_MARGIN, padding: 7.5, height: 49 }; function MoreTabTarget({ itemCount, onPress }) { const { width } = useWindowDimensions(); const insets = useSafeAreaInsets(); const slot = (width - 2 * (IOS_TAB_BAR.margin + IOS_TAB_BAR.padding)) / itemCount; return ( ); } /** * Android: the bottom bar tints every tab icon with one color list, so the * avatar can't be a native tab icon (it would come out as a flat tinted disc). * It is drawn over its tab instead, centered on the Material 3 icon slot — * 28dp below the bar's top edge, with or without labels. With `onPress` the * whole tab column is the More target (the native tab is `disabled` while it * opens the sheet); without, touches go through to the native tab. */ const ANDROID_TAB_ICON_CENTER = 28; function AndroidTabOverlay({ slotIndex, slotCount, barHeight, avatar, onPress, accessibilityLabel }) { const { width } = useWindowDimensions(); const slot = width / slotCount; return ( {avatar ? ( ) : null} ); } /** Transparent hit target over the native More tab — opens the same popup as JS tabs (without a native sheet). */ function NativeTabsMoreOverlay({ visible, isShowTabs, overflowMenuItems, onSelectOverflow, moreMenuOpen, onMoreMenuOpenChange, onMoreTriggerPress, tabBarHeight, }) { const { t } = useTranslation(); if (!isShowTabs || !overflowMenuItems?.length) return null; return ( {visible.map((tab, index) => ( ))} onMoreTriggerPress()} /> ); } export default function ExpoUITabNavigator({ TabList, currentUser, colors, isShowTabs, tabsSessionKey, }) { const { t } = useTranslation(); const pathname = usePathname(); const router = useRouter(); const insets = useSafeAreaInsets(); const { setCurrentUser } = useCurrentUser(); const { setBottomSheetData } = useBottomSheetData(); const hideLabels = areTabBarLabelsHidden(TabList); const avatarIcon = useAvatarTabIcon(isIos ? currentUser?.avatar : null); // Own avatar as a tab icon: the `{profile}` item, or the dashboard in menus without one. const hasProfileTab = TabList.some(isProfileTab); const usesAvatarIcon = (tab) => (hasProfileTab ? isProfileTab(tab) : isDashboardTab(tab, currentUser)); const { visible, overflow, moreTabIndex } = useMemo( () => splitTabBarItems(TabList), [TabList] ); // Overflow page the More tab shows (sheet highlight). It outlives visits to // other tabs — the More tab comes back on it — and resets with the session. const [activeOverflow, setActiveOverflow] = useState({ session: tabsSessionKey, url: null }); const activeOverflowUrl = activeOverflow.session === tabsSessionKey ? activeOverflow.url : null; const [moreMenuOpen, setMoreMenuOpen] = useState(false); // Operator agent: opened from the More menu (same rule as pages skipping // their floating button, `hasNativeTabsMoreMenu`) and shown over every tab. const agentData = useOperatorAgentData(overflow.length > 0 && !!isShowTabs); const agentParams = useGlobalSearchParams(); const [agentOpen, setAgentOpen] = useState(false); const toggleAgent = () => { setMoreMenuOpen(false); setAgentOpen((open) => !open); }; const { selectedInk, selectedIndicator, badgeBackground } = useTabBarColors(colors); // JS popup's hit area over the More tab; the Android bar pads itself with the system inset. const tabBarHeight = isAndroid ? androidTabBarHeight(hideLabels) + insets.bottom : IOS_TAB_BAR_HEIGHT; // Agent card sits above the bar, where the page float used to. const agentBottomOffset = 16 + (isAndroid ? tabBarHeight : IOS_TAB_BAR.height + (getWindowSafeAreaInsets().bottom || 0)); const overflowMenuItems = useMemo( () => buildTabBarMoreMenuItems({ visible, overflow, moreTabIndex, currentUser, t, pathname, activeOverflowUrl, mode: 'native', }), [visible, overflow, moreTabIndex, currentUser, t, pathname, activeOverflowUrl] ); // Plain function — React Compiler memoizes it (manual useCallback here // could not be preserved by the compiler). const handleOverflowSelect = async (item) => { if (item?.id === OPERATOR_AGENT_ITEM_ID) { toggleAgent(); return; } const tabUrl = await selectOverflowTab({ item, currentUser, setCurrentUser, setBottomSheetData, router, moreTabIndex, }); if (!tabUrl) return; setMoreMenuOpen(false); if (item.isOverflow) setActiveOverflow({ session: tabsSessionKey, url: tabUrl }); }; const collapsed = overflow.length > 0; const moreRoot = overflow[0]; // A `{profile}` item heading the More list makes the More tab the user's // avatar and the sheet's header (Linear style); web's footer does the same. const profileMore = collapsed && isProfileTab(moreRoot); const sheetExclude = appSetting('theme', 'expo_ui', 'tabs')?.more_sheet_exclude || []; const profileMenu = splitProfileMoreMenu({ items: overflowMenuItems, overflow, hasAgent: !!agentData, exclude: sheetExclude, }); const sheetHeader = profileMore && hasNativeMoreSheet ? profileMenu.header : null; const sheetItems = sheetHeader ? profileMenu.items : trimMenuSeparators(overflowMenuItems.filter((item) => !sheetExclude.includes(item.tab?.url))); // The profile is the only More link left: the avatar opens it directly // (unless the sheet is also where the operator agent opens). const profileOnly = !!sheetHeader && profileMenu.profileOnly; // Popup More menu (Android, iOS without the sheet): Agent heads the list, // shown selected while its chat is open. const popupMenuItems = agentData ? [ { id: OPERATOR_AGENT_ITEM_ID, title: t('operator_agent_title'), icon: 'Sparkles', selected: agentOpen }, { ...tabBarMoreSeparator(), id: `${OPERATOR_AGENT_ITEM_ID}-separator` }, ...overflowMenuItems, ] : overflowMenuItems; // The menu opens over the current page: `MoreTabTarget` takes the tap on // iPhone, and the `disabled` native tab covers the rest (edge taps, // VoiceOver, iPad). With the profile as the only More link the avatar is a // real tab instead — the press selects it and the profile shows. // Plain values, like the handlers above — React Compiler memoizes them. const handleMoreTabPress = () => { if (profileOnly) { rememberSelectedTab(`/tab${moreTabIndex}`); return; } playTabFeedback(); setMoreMenuOpen(true); }; // More presses go to the trigger's own `listeners`; every tab press gets the click. const moreTabListeners = { tabPress: handleMoreTabPress }; const screenListeners = { tabPress: () => playTabFeedback() }; const moreIcons = resolveTabIcons(profileMore ? moreRoot.icon : MORE_TAB_ICON); const moreTitle = profileMore ? t(moreRoot.title) : t('More'); const overflowBadge = getOverflowBadgeLabel(currentUser, overflow); // Android: avatar and More target drawn over the bar (see AndroidTabOverlay). // Slots are the tabs the bar shows, plus More. const barTabs = (collapsed ? visible : TabList).filter((tab) => tab.hide !== true); const slotCount = barTabs.length + (collapsed ? 1 : 0); const androidAvatar = isAndroid && currentUser?.avatar ? avatarImageUri(currentUser.avatar, TAB_AVATAR_PT) : null; const avatarSlot = collapsed && profileMore ? barTabs.length : barTabs.findIndex(usesAvatarIcon); const androidMoreTarget = isAndroid && isShowTabs && collapsed && hasNativeMoreSheet && !profileOnly; return ( {isIos && collapsed && hasNativeMoreSheet && isShowTabs && !profileOnly && !Platform.isPad ? ( tab.hide !== true).length + 1} onPress={handleMoreTabPress} /> ) : null} {collapsed && hasNativeMoreSheet && isShowTabs ? ( ) : null} {androidMoreTarget ? ( ) : null} {isAndroid && isShowTabs && androidAvatar && avatarSlot >= 0 && !(androidMoreTarget && avatarSlot === barTabs.length) ? ( ) : null} {collapsed && !hasNativeMoreSheet ? ( ) : null} {agentData ? ( setAgentOpen(false)} bottomOffset={agentBottomOffset} params={agentParams} /> ) : null} ); }