ソースを参照

Native tabs fixes (#5808)

* Neo: fix JS tab routes after the per-tab native stack

Each tab is now a stack (tabN/_layout), so its route is `tabN`, not
`tabN/index`. NativeTabNavigator (used when `native.expo_native_tabs` is
off) still declared `tabN/index`, so expo-router added its own tabs with
the route names as labels and no icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: make dashboard tiles tappable on native

With `mode="plain"` Link hands its press to the child through asChild,
and the tile's child is a Row (a View), which drops it. Use the default
mode so the tile is wrapped in a Pressable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: share More-menu pick, tab bar colors and overflow badge between tab navigators

JS tabs and NativeTabs each had their own copy of the More-menu pick
(external URL, notification clear, navigate to the tab), the
`native_tabs` color resolution and the overflow badge sum.

- tab-press: clearNotifCounter and selectOverflowTab
- tab-colors: useTabBarColors (selected ink / indicator, badge fill)
- badges: getOverflowBadgeTotal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 1 週間 前
親
コミット
db9336ca5f

+ 1 - 1
neo/packages/app/components/elements/dashboard-stat.js

@@ -62,7 +62,7 @@ function DashboardStatTile({ href, icon, title, count, trailing }) {
 
     return (
         <View className={cn(dropdownTheme.cnt, 'relative z-0 w-full min-w-0')}>
-            <Link href={href} mode="plain" className="block w-full min-w-0" alt={title}>
+            <Link href={href} className="block w-full min-w-0" alt={title}>
                 <Row className={cn(menuSettings.item_ver, 'min-h-0 items-center py-2')}>
                     <View className="min-w-0 flex-auto gap-1">
                         {icon ? (

+ 11 - 0
neo/packages/app/components/nav/tabs/badges.js

@@ -71,3 +71,14 @@ export function getBadgeLabel(currentUser, tab) {
     }
     return null;
 }
+
+/** Sum of the overflow tabs' badges, for the More tab. 0 when none. */
+export function getOverflowBadgeTotal(currentUser, overflow = []) {
+    let total = 0;
+    for (const tab of overflow) {
+        if (tab?.hide === true) continue;
+        const n = parseInt(getBadgeLabel(currentUser, tab), 10);
+        if (n > 0) total += n;
+    }
+    return total;
+}

+ 16 - 51
neo/packages/app/components/nav/tabs/expoui-tabs.js

@@ -10,7 +10,7 @@ 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, findIconFromRemote, isAndroid, isIos, FeedbackHaptics, clearNotif, IOS_TAB_BAR_MARGIN } from 'app/lib/util';
+import { androidTabBarHeight, appSetting, 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';
@@ -19,15 +19,13 @@ 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 } from './badges';
+import { getBadgeLabel, getOverflowBadgeTotal } from './badges';
 import {
     MORE_TAB_ICON,
     areTabBarLabelsHidden,
     buildTabBarMoreMenuItems,
     isDashboardTab,
-    isExternalTabUrl,
     isProfileTab,
-    resolveTabUrl,
     splitProfileMoreMenu,
     splitTabBarItems,
     tabBarMoreSeparator,
@@ -36,11 +34,11 @@ import {
 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 { useNativeTokenColor } from 'app/design/controls/neo-button/native-style-colors';
+import { useTabBarColors } from './tab-colors';
+import { selectOverflowTab } from './tab-press';
 import { getWindowSafeAreaInsets, useGlobalSearchParams, useRouter, usePathname, useSafeAreaInsets } from 'app/lib/hooks/router';
-import { nativeTabHref, rememberSelectedTab } from 'app/lib/navigation/tab-history';
+import { rememberSelectedTab } from 'app/lib/navigation/tab-history';
 import emitter, { EVENTS } from 'app/context/emitter';
-import * as WebBrowser from 'expo-web-browser';
 import { useBottomSheetData } from 'app/context/bottomsheet';
 import { useCurrentUser } from 'app/context/user';
 
@@ -79,14 +77,7 @@ function tabIconProps(icons) {
 }
 
 function getOverflowBadgeLabel(currentUser, overflow = []) {
-    let total = 0;
-    for (const tab of overflow) {
-        if (tab?.hide === true) continue;
-        const label = getBadgeLabel(currentUser, tab);
-        if (!label) continue;
-        const n = parseInt(label, 10);
-        if (n > 0) total += n;
-    }
+    const total = getOverflowBadgeTotal(currentUser, overflow);
     if (total <= 0) return null;
     return total > 99 ? '99+' : String(total);
 }
@@ -358,11 +349,7 @@ export default function ExpoUITabNavigator({
         setAgentOpen((open) => !open);
     };
 
-    // Selected tab: the toggled look of selected glass buttons (`native_tabs.selected`).
-    const tabsSelected = appSetting('theme', 'native_tabs', 'selected');
-    const selectedInk = useNativeTokenColor(tabsSelected?.foreground) || colors.primary;
-    const selectedIndicator = useNativeTokenColor(tabsSelected?.indicator) || colors.primaryBg;
-    const badgeBackground = useNativeTokenColor(appSetting('theme', 'native_tabs', 'badgeBackground') || 'bg-destructive');
+    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;
@@ -400,39 +387,17 @@ export default function ExpoUITabNavigator({
             toggleAgent();
             return;
         }
-        if (item?.type === 'separator' || !item?.tab) return;
-
-        const tab = item.tab;
-        const tabUrl = resolveTabUrl(tab, currentUser);
-        const routeIndex = item.isOverflow ? moreTabIndex : item.tabIndex;
-        setBottomSheetData(null);
-
-        if (isExternalTabUrl(tabUrl)) {
-            await WebBrowser.openBrowserAsync(tabUrl);
-            FeedbackHaptics('Medium');
-            return;
-        }
-
-        const notificationUrl = appSetting('notifications', 'url');
-        if (tabUrl === notificationUrl) {
-            clearNotif();
-            setCurrentUser({
-                notifications: 0,
-                notificationsTs: Date.now(),
-                counters: {
-                    ...(currentUser?.counters || {}),
-                    bx_notifications: 0,
-                },
-            });
-        }
-
-        playTabFeedback();
+        const tabUrl = await selectOverflowTab({
+            item,
+            currentUser,
+            setCurrentUser,
+            setBottomSheetData,
+            router,
+            moreTabIndex,
+        });
+        if (!tabUrl) return;
         setMoreMenuOpen(false);
         if (item.isOverflow) setActiveOverflow({ session: tabsSessionKey, url: tabUrl });
-        rememberSelectedTab(`/tab${routeIndex}`);
-        // The tab's root shows the picked page; `navigate` updates the existing
-        // root (popping pages above it) where `push` would stack a second one.
-        router.navigate(nativeTabHref(tabUrl, `/tab${routeIndex}`));
     };
 
     const collapsed = overflow.length > 0;

+ 20 - 63
neo/packages/app/components/nav/tabs/native-tabs.js

@@ -2,43 +2,26 @@ import { Tabs as RouterTabs, useRouter, usePathname } from 'app/lib/hooks/router
 import { View } from 'app/design/view';
 import { Icon } from 'app/ui/atoms/icon';
 import { useCurrentUser } from 'app/context/user';
-import { appSetting, isTabBarLabelsEnabled, FeedbackHaptics, clearNotif } from 'app/lib/util';
+import { appSetting, isTabBarLabelsEnabled } from 'app/lib/util';
 import { Platform } from 'react-native';
 import Profile from 'app/ui/molecules/profile/profile';
 import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import * as WebBrowser from 'expo-web-browser';
 import DropdownMenu from 'app/ui/atoms/dropdown-menu';
-import { nativeTabHref, rememberSelectedTab } from 'app/lib/navigation/tab-history';
-import { playTabFeedback } from 'app/components/nav/tab-feedback';
 import { Text } from 'app/design/typography';
-import { getBadgeForTab, getBadgeLabel } from './badges';
-import { isDashboardTab, isExternalTabUrl, resolveTabUrl, splitTabBarItems, buildTabBarMoreMenuItems, MORE_TAB_ICON } from './tab-menu';
-import { handleTabPress } from './tab-press';
+import { getBadgeForTab, getOverflowBadgeTotal } from './badges';
+import { isDashboardTab, resolveTabUrl, splitTabBarItems, buildTabBarMoreMenuItems, MORE_TAB_ICON } from './tab-menu';
+import { handleTabPress, selectOverflowTab } from './tab-press';
+import { useTabBarColors } from './tab-colors';
 import { useBottomSheetData } from 'app/context/bottomsheet';
-import { useNativeTokenColor } from 'app/design/controls/neo-button/native-style-colors';
 
 const TAB_ICON_SIZE = 24;
 const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 52 : 56;
 const DETACH_INACTIVE_SCREENS = Platform.OS !== 'ios';
 const TAB_BAR_ITEM_STYLE = appSetting('theme', 'native_tabs', 'tabBarItemStyle');
-const TAB_BAR_BADGE_BACKGROUND = appSetting('theme', 'native_tabs', 'badgeBackground') || 'bg-destructive';
-const TAB_BAR_SELECTED = appSetting('theme', 'native_tabs', 'selected');
-
-function getOverflowBadgeCount(currentUser, overflow = []) {
-    let total = 0;
-    for (const tab of overflow) {
-        if (tab?.hide === true) continue;
-        const label = getBadgeLabel(currentUser, tab);
-        if (!label) continue;
-        const n = parseInt(label, 10);
-        if (n > 0) total += n;
-    }
-    return total;
-}
 
 function getBadgeForOverflow(currentUser, overflow = []) {
-    const count = getOverflowBadgeCount(currentUser, overflow);
+    const count = getOverflowBadgeTotal(currentUser, overflow);
     if (count <= 0) return null;
     const badgeTextSize = appSetting('theme', 'native_tabs', 'badgeTextSize') || 'text-xs';
     return (
@@ -129,8 +112,8 @@ function getScreenOptions(colors, isShowTabs, tokens) {
         },
         tabBarAllowFontScaling: false,
         tabBarInactiveTintColor: colors.barsColor,
-        tabBarActiveTintColor: tokens.selectedInk || colors.primary,
-        tabBarActiveBackgroundColor: tokens.selectedIndicator || colors.primaryBg,
+        tabBarActiveTintColor: tokens.selectedInk,
+        tabBarActiveBackgroundColor: tokens.selectedIndicator,
         unmountOnBlur: false,
         lazy: true,
         sceneStyle: {
@@ -182,10 +165,7 @@ export default function NativeTabNavigator({
     const router = useRouter();
     const { t } = useTranslation();
 
-    // Selected tab: the toggled look of selected glass buttons (`native_tabs.selected`).
-    const badgeBackground = useNativeTokenColor(TAB_BAR_BADGE_BACKGROUND);
-    const selectedInk = useNativeTokenColor(TAB_BAR_SELECTED?.foreground);
-    const selectedIndicator = useNativeTokenColor(TAB_BAR_SELECTED?.indicator);
+    const { badgeBackground, selectedInk, selectedIndicator } = useTabBarColors(colors);
 
     const screenOptions = useMemo(
         () => getScreenOptions(colors, isShowTabs, { badgeBackground, selectedInk, selectedIndicator }),
@@ -214,38 +194,15 @@ export default function NativeTabNavigator({
     );
 
     const handleOverflowSelect = useCallback(async (item) => {
-        if (item?.type === 'separator' || !item?.tab) return;
-
-        const tab = item.tab;
-        const tabUrl = resolveTabUrl(tab, currentUser);
-        const routeIndex = item.isOverflow ? moreTabIndex : item.tabIndex;
-        setBottomSheetData(null);
-
-        if (isExternalTabUrl(tabUrl)) {
-            await WebBrowser.openBrowserAsync(tabUrl);
-            FeedbackHaptics('Medium');
-            return;
-        }
-
-        const notificationUrl = appSetting('notifications', 'url');
-        if (tabUrl === notificationUrl) {
-            clearNotif();
-            setCurrentUser({
-                notifications: 0,
-                notificationsTs: Date.now(),
-                counters: {
-                    ...(currentUser?.counters || {}),
-                    bx_notifications: 0,
-                },
-            });
-        }
-
-        playTabFeedback();
-        setActiveOverflowUrl(item.isOverflow ? tabUrl : null);
-        rememberSelectedTab(`/tab${routeIndex}`);
-        // The tab's root shows the picked page; `navigate` updates the existing
-        // root (popping pages above it) where `push` would stack a second one.
-        router.navigate(nativeTabHref(tabUrl, `/tab${routeIndex}`));
+        const tabUrl = await selectOverflowTab({
+            item,
+            currentUser,
+            setCurrentUser,
+            setBottomSheetData,
+            router,
+            moreTabIndex,
+        });
+        if (tabUrl) setActiveOverflowUrl(item.isOverflow ? tabUrl : null);
     }, [currentUser, moreTabIndex, router, setBottomSheetData, setCurrentUser]);
 
     const moreTab = overflow[0];
@@ -258,7 +215,7 @@ export default function NativeTabNavigator({
             screenOptions={screenOptions}
         >
             {visible.map((tab, index) => {
-                const tabRouteName = `tab${index}/index`;
+                const tabRouteName = `tab${index}`;
                 const tabUrl = resolveTabUrl(tab, currentUser);
                 return (
                     <RouterTabs.Screen
@@ -286,7 +243,7 @@ export default function NativeTabNavigator({
             {moreTab ? (
                 <RouterTabs.Screen
                     key={`tab${moreTabIndex}`}
-                    name={`tab${moreTabIndex}/index`}
+                    name={`tab${moreTabIndex}`}
                     initialParams={{
                         url: resolveTabUrl(moreTab, currentUser),
                         name: `tab${moreTabIndex}`,

+ 15 - 0
neo/packages/app/components/nav/tabs/tab-colors.js

@@ -0,0 +1,15 @@
+import { appSetting } from 'app/lib/util';
+import { useNativeTokenColor } from 'app/design/controls/neo-button/native-style-colors';
+
+/**
+ * Tab bar colors from `theme.native_tabs` (JS tabs and NativeTabs): the
+ * selected tab's ink and indicator (the toggled look of selected glass
+ * buttons) and the badge fill. Hex or `bg-*` / `text-*` tokens.
+ */
+export function useTabBarColors(colors) {
+    const selected = appSetting('theme', 'native_tabs', 'selected');
+    const selectedInk = useNativeTokenColor(selected?.foreground) || colors.primary;
+    const selectedIndicator = useNativeTokenColor(selected?.indicator) || colors.primaryBg;
+    const badgeBackground = useNativeTokenColor(appSetting('theme', 'native_tabs', 'badgeBackground') || 'bg-destructive');
+    return { selectedInk, selectedIndicator, badgeBackground };
+}

+ 52 - 9
neo/packages/app/components/nav/tabs/tab-press.js

@@ -6,6 +6,7 @@ import {
     hasPushedScreens,
     isAtTabRoot,
     isSelectedTab,
+    nativeTabHref,
     navigateToTabRoot,
     rememberSelectedTab,
 } from 'app/lib/navigation/tab-history';
@@ -21,6 +22,56 @@ function scheduleTabFeedback(defer) {
     playTabFeedback();
 }
 
+/** Marks notifications read: on UNA and in the session's counters. */
+export function clearNotifCounter(currentUser, setCurrentUser) {
+    clearNotif();
+    setCurrentUser({
+        notifications: 0,
+        notificationsTs: Date.now(),
+        counters: {
+            ...(currentUser?.counters || {}),
+            bx_notifications: 0,
+        },
+    });
+}
+
+/**
+ * Shared More-menu pick (JS tabs and NativeTabs): opens an external URL in the
+ * browser, or shows the page on its tab — the More tab for an overflow item.
+ * Returns the tab URL it navigated to, or null when it didn't navigate.
+ */
+export async function selectOverflowTab({
+    item,
+    currentUser,
+    setCurrentUser,
+    setBottomSheetData,
+    router,
+    moreTabIndex,
+}) {
+    if (item?.type === 'separator' || !item?.tab) return null;
+
+    const tabUrl = resolveTabUrl(item.tab, currentUser);
+    const routeIndex = item.isOverflow ? moreTabIndex : item.tabIndex;
+    setBottomSheetData(null);
+
+    if (isExternalTabUrl(tabUrl)) {
+        await WebBrowser.openBrowserAsync(tabUrl);
+        FeedbackHaptics('Medium');
+        return null;
+    }
+
+    if (tabUrl === appSetting('notifications', 'url')) {
+        clearNotifCounter(currentUser, setCurrentUser);
+    }
+
+    playTabFeedback();
+    rememberSelectedTab(`/tab${routeIndex}`);
+    // The tab's root shows the picked page; `navigate` updates the existing
+    // root (popping pages above it) where `push` would stack a second one.
+    router.navigate(nativeTabHref(tabUrl, `/tab${routeIndex}`));
+    return tabUrl;
+}
+
 /**
  * Shared tab-press: external URL, notif clear, re-tap root/reset, haptic.
  * First tap of a tab restores its last page (navigator switch). A consecutive
@@ -57,15 +108,7 @@ export async function handleTabPress({
     const tabKey = `/tab${tabIndex}`;
     const shouldClearNotif = isJs ? e.type === 'tabPress' : true;
     if (shouldClearNotif && tabUrl === notificationUrl) {
-        clearNotif();
-        setCurrentUser({
-            notifications: 0,
-            notificationsTs: Date.now(),
-            counters: {
-                ...(currentUser?.counters || {}),
-                bx_notifications: 0,
-            },
-        });
+        clearNotifCounter(currentUser, setCurrentUser);
     }
 
     // Use last-selected tab, not pathname. NativeTabs may focus the target