diff --git a/sources/components/MainView.tsx b/sources/components/MainView.tsx index bc66dd4f2..f84fbe26d 100644 --- a/sources/components/MainView.tsx +++ b/sources/components/MainView.tsx @@ -8,7 +8,8 @@ import { useRouter } from 'expo-router'; import { EmptySessionsTablet } from './EmptySessionsTablet'; import { SessionsList } from './SessionsList'; import { FABWide } from './FABWide'; -import { TabBar, TabType } from './TabBar'; +import { TabBar } from './TabBar'; +import { useTabState, TabType } from '@/hooks/useTabState'; import { InboxView } from './InboxView'; import { SettingsViewWrapper } from './SettingsViewWrapper'; import { SessionsListWrapper } from './SessionsListWrapper'; @@ -231,9 +232,9 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { const friendRequests = useFriendRequests(); const realtimeStatus = useRealtimeStatus(); - // Tab state management + // Tab state management - persisted to server via KV API // NOTE: Zen tab removed - the feature never got to a useful state - const [activeTab, setActiveTab] = React.useState('sessions'); + const { activeTab, setActiveTab, isLoading: isTabLoading } = useTabState(); const handleNewSession = React.useCallback(() => { router.push('/new'); @@ -241,7 +242,7 @@ export const MainView = React.memo(({ variant }: MainViewProps) => { const handleTabPress = React.useCallback((tab: TabType) => { setActiveTab(tab); - }, []); + }, [setActiveTab]); // Regular phone mode with tabs - define this before any conditional returns const renderTabContent = React.useCallback(() => { diff --git a/sources/components/TabBar.tsx b/sources/components/TabBar.tsx index 73c70f6d0..c13ede9e1 100644 --- a/sources/components/TabBar.tsx +++ b/sources/components/TabBar.tsx @@ -8,7 +8,9 @@ import { Typography } from '@/constants/Typography'; import { layout } from '@/components/layout'; import { useInboxHasContent } from '@/hooks/useInboxHasContent'; -export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings'; +// Import and re-export TabType from the hook for backward compatibility +import type { TabType } from '@/hooks/useTabState'; +export type { TabType }; interface TabBarProps { activeTab: TabType; diff --git a/sources/hooks/useTabState.ts b/sources/hooks/useTabState.ts new file mode 100644 index 000000000..376fa34e8 --- /dev/null +++ b/sources/hooks/useTabState.ts @@ -0,0 +1,129 @@ +import React from 'react'; +import { useAuth } from '@/auth/AuthContext'; +import { kvGet, kvSet, KvItem } from '@/sync/apiKv'; + +/** + * Tab types for the main navigation + * Note: 'zen' is included for TabBar compatibility but not actively used + */ +export type TabType = 'zen' | 'inbox' | 'sessions' | 'settings'; + +const TAB_STATE_KEY = 'ui:active-tab'; +const DEFAULT_TAB: TabType = 'sessions'; + +interface TabState { + activeTab: TabType; + version: number; +} + +/** + * Hook for persistent tab state that syncs to the server. + * + * Features: + * - Loads tab state from server on mount + * - Saves tab changes to server with optimistic updates + * - Handles version conflicts gracefully + * - Falls back to 'sessions' if no saved state + * + * Usage: + * const { activeTab, setActiveTab, isLoading } = useTabState(); + */ +export function useTabState() { + const { credentials } = useAuth(); + const [state, setState] = React.useState({ + activeTab: DEFAULT_TAB, + version: -1 + }); + const [isLoading, setIsLoading] = React.useState(true); + + // Load initial state from server + React.useEffect(() => { + if (!credentials) { + setIsLoading(false); + return; + } + + let mounted = true; + + async function loadTabState() { + try { + const item = await kvGet(credentials!, TAB_STATE_KEY); + + if (!mounted) return; + + if (item) { + const tab = item.value as TabType; + // Validate the tab value (zen is excluded as it's not active) + if (tab === 'sessions' || tab === 'inbox' || tab === 'settings') { + setState({ + activeTab: tab, + version: item.version + }); + } + } + } catch (error) { + console.warn('[TabState] Failed to load tab state:', error); + } finally { + if (mounted) { + setIsLoading(false); + } + } + } + + loadTabState(); + + return () => { + mounted = false; + }; + }, [credentials]); + + // Set active tab with server sync + const setActiveTab = React.useCallback(async (tab: TabType) => { + // Optimistic update + setState(prev => ({ + activeTab: tab, + version: prev.version + })); + + if (!credentials) return; + + try { + const newVersion = await kvSet( + credentials, + TAB_STATE_KEY, + tab, + state.version + ); + + setState(prev => ({ + ...prev, + version: newVersion + })); + } catch (error) { + console.warn('[TabState] Failed to save tab state:', error); + // On conflict, reload the current state from server + if (String(error).includes('version-mismatch')) { + try { + const item = await kvGet(credentials, TAB_STATE_KEY); + if (item) { + const serverTab = item.value as TabType; + if (serverTab === 'sessions' || serverTab === 'inbox' || serverTab === 'settings' || serverTab === 'zen') { + setState({ + activeTab: serverTab, + version: item.version + }); + } + } + } catch { + // Ignore reload errors + } + } + } + }, [credentials, state.version]); + + return { + activeTab: state.activeTab, + setActiveTab, + isLoading + }; +}