fix(mobile): dedicated mobile layout with bottom navigation#301
Open
fix(mobile): dedicated mobile layout with bottom navigation#301
Conversation
The desktop metaphor (TabBar, floating chat bubbles, desktop icons) doesn't work on mobile. Add a fixed bottom navigation bar visible below lg (1024px) with 4 tabs: Home, Messages, Wallet, Apps. New components: - MobileBottomNav: fixed bottom bar with unread badge on Messages - MobileMessagesView: full-screen conversation list + chat with slide transitions - MobileWalletView: full-screen WalletPanel wrapper - MobileAppsView: full-screen app grid (reuses DesktopShortcuts) - useMobileNav: state hook (useSyncExternalStore, no persistence) Integration: - DashboardLayout renders MobileBottomNav + mobile overlay views - DesktopLayout hides TabBar on mobile (hidden lg:block) - Content area gets pb-16 lg:pb-0 for bottom nav clearance - Safe area inset for iOS gesture bar No changes to desktop layout (lg+ screens).
Steelman review found that MobileMessagesView/MobileWalletView/MobileAppsView
created duplicate instances of DMChatSection, WalletPanel, and DesktopShortcuts
alongside the existing components rendered by DesktopLayout. This caused
divergent useChat state, localStorage write races, and conflicting modal state.
Refactor the bottom nav to drive existing desktop state instead of mounting
parallel components:
- Home/Apps: navigate('/home', {replace:true}) + showDesktop()
- Messages: navigate('/agents/dm') + openTab('dm') + setWalletOpen(false)
- Wallet: setWalletOpen(true)
Delete MobileMessagesView, MobileWalletView, MobileAppsView.
Simplify useMobileNav to only expose {isMobile}.
Additional fixes from the same review:
- DMChatSection: auto-open sidebar on mobile when no conversation selected
(fixes "DM sidebar hidden by default" UX issue)
- DashboardLayout: reset fullscreen when viewport shrinks to mobile
(prevents fullscreen trap after desktop->mobile resize)
- MobileBottomNav: z-[150] (avoids collision with TutorialOverlay z-200),
hide when keyboard open via useVisualViewport.isKeyboardOpen
- useMobileNav: lazy matchMedia binding to prevent HMR listener leak
- index.css: safe-area-bottom uses max(env(...), 8px) for old WebView fallback
- pre-commit hook: fix false positive when lint produces no output
Critical fixes: - DMChatSection: convert sidebar auto-open to one-shot ref-gated effect, eliminating race where useChat's async localStorage restore would slam the sidebar shut after the user saw the conversation list - MobileBottomNav: drop redundant "Apps" tab (was identical to Home, causing dual highlight on /home). Now 3 tabs: Home, Messages, Wallet - MobileBottomNav: make handleTap idempotent (only call state setters when state actually changes) — fixes Home toggle bug where tapping Home on Home yanked the user to the previous tab Warning fixes: - MobileBottomNav: Messages tab now uses navigate with replace:true - useVisualViewport: snapshot-baseline keyboard detection now works on Android Chrome with interactive-widget=resizes-content. Baseline self-heals on non-keyboard resizes (orientation, window resize) - DashboardLayout: fullscreen escape effect only fires on desktop->mobile transition (tracked via prevIsMobileRef), not every render - useUIState: setFullscreen and toggleFullscreen no-op when target is true on mobile (guards against entering unexit-able state) - DMChatInput: remove redundant safe-area padding (outer layout handles it) - index.css: safe-area-bottom fallback raised 8px -> 16px for Android gesture-area clearance; added --mobile-nav-height CSS var - DashboardLayout: content padding uses .pb-mobile-nav (tracks real nav height 64-98px) instead of fixed pb-16 that occluded bottom 8-34px
Critical fixes: - DashboardLayout: remove duplicated pb-mobile-nav from base class; each ternary branch (fullscreen/agent/default) now owns its own padding-bottom. Fullscreen truly gets p-0; non-agent pages no longer have conflicting lg:pb-0 and lg:pb-8 simultaneously. - MobileBottomNav: Home tap now gates showDesktop() on activeTabId !== null (direct condition) instead of composite onDesktop (which mixed in walletOpen). Prevents the toggle bug where tapping Home with wallet open would yank the user to the previous tab. - useVisualViewport: baseline initializes from max(innerHeight, screen.availHeight) to resist keyboard-on-mount poisoning (autofocus, BFCache). Orientation changes are detected via width comparison (keyboards don't change width) and recalibrate the baseline instead of false-firing isKeyboardOpen. Listens to screen.orientation.change as a second signal. - useUIState: isFullscreen masked as false on mobile consumers, defense-in- depth against BFCache/persisted-state trapping users in mobile fullscreen. Warning fixes: - DMChatSection: reset didInitSidebarRef when activeTabId leaves 'dm' (component stays mounted via display:none, so one-shot now re-fires on re-entry — tap Messages always shows the conversation list on mobile) - useMobileNav: ref-counted per-subscriber matchMedia binding. Listener attaches on first subscribe, detaches on last unsubscribe. Prevents orphaned listeners under HMR / Vitest module reset. - useDesktopState: defaultState -> getDefaultState() function, deferring matchMedia call from module-load time. update() skips saveState when updater returns prev unchanged (avoids pointless localStorage writes). - useUIState: remove ...defaultState spread that was a no-op but would silently wipe future fields as UIState grows. - index.css: .pb-mobile-nav media query upper bound 1023px -> 1023.98px (closes fractional-zoom gap at Retina displays). useMobileNav breakpoint matches. --mobile-nav-height includes the 1px border-t for pixel accuracy. - DesktopLayout: rounded-2xl scoped to lg:rounded-2xl so mobile wallet slide-over is full-bleed (no floating-pill look).
…seline Round-3 changed useVisualViewport to use max(innerHeight, screen.availHeight) as the keyboard-detection baseline. Intent was to resist keyboard-on-mount poisoning. But on Android with the browser URL bar visible, screen.availHeight (full device screen minus system bar) is typically 100+px larger than innerHeight (screen minus URL bar minus system bar). Result: on every mobile page load, baseline - innerHeight > 150px → isKeyboardOpen = true at mount → nav translates off-screen with translate-y-full → users see no bottom nav. Fix: use plain window.innerHeight as initial baseline. The self-heal branch (delta < 50) still handles growth when the keyboard closes. The keyboard-on- mount edge case (baseline captures shrunk height) is rare and recovers on first keyboard close. This is the same pattern used by most production mobile apps and matches the pre-round-3 behavior that worked correctly.
Critical fixes:
- MobileBottomNav: Messages tab gated on `pathname === '/agents/dm'` to
prevent stale-highlight when user navigates to /markets via header while
activeTabId is still 'dm' (left over from previous DM session).
- useVisualViewport: use screen.height * 0.6 as a "definitely no-keyboard"
signal to recover from poisoned baseline (BFCache restore, autofocus race
during mount). Previously the self-heal only triggered after the keyboard
closed once — on Android with interactive-widget=resizes-content, innerHeight
shrinks WITH visualViewport.height so delta stays small during keyboard-open,
and detection was effectively off for the whole session if baseline was
initially poisoned.
Warning fixes:
- DMChatSection + MobileBottomNav: dispatch `mobile-nav-messages-tap`
CustomEvent so tapping Messages while already on DM tab re-opens the
conversation list (previously openTab('dm') was idempotent → activeTabId
unchanged → sidebar effect didn't re-fire → user stuck in chat).
- useVisualViewport: self-heal no longer ratchets baseline monotonically
(Math.max(innerHeight, height) instead of Math.max(baseHeight, innerHeight,
height)) — prevents desktop resize inflation.
- useVisualViewport: orientation handler now calls updateViewport() after the
300ms settle so consumers re-render with corrected baseline immediately.
- useVisualViewport: orientationInProgressRef flag forces isKeyboardOpen=false
during the 300ms rotation window, suppressing false-positive flashes on iOS
where visualViewport.resize fires before innerWidth updates.
- useMobileNav: getSnapshot refreshes isMobileValue from matchMedia when no
subscriber is attached, preventing stale module-load value on first render.
- useMobileNav: cache MediaQueryList at module scope so add/removeEventListener
operate on the same instance — prevents listener leaks on browsers that
return different MQL objects across matchMedia() calls.
- index.css: remove phantom +1px from --mobile-nav-height. Tailwind Preflight
uses box-sizing: border-box, so border-t is rendered INSIDE h-16 (64px),
not added on top.
Viewport hook robustness (useVisualViewport.ts): - Replace screen.height with max(innerWidth, innerHeight) as reference axis. Chrome Android reports rotation-invariant screen.height (stays at portrait value in landscape) so the previous self-heal heuristic never fired in landscape. Long-axis reference is orientation-aware. - Tighten threshold from 0.6 to 0.7 — catches keyboards occupying 30-40% of screen (landscape keyboards, split/compact Gboard on tablets) that were previously silently missed. - Unify the two baseline-grow branches (screen-ratio grow + delta<50 self- heal) into a single rule: if (likelyNoKeyboard || delta < 50) grow. They previously ran sequentially with C overwriting B's work. - Track orientation timer in ref and clearTimeout on each trigger. Rapid rotation no longer leaks callbacks or prematurely clears the in-progress flag. - Short-circuit path (orientationInProgressRef) now updates prevWidthRef and opportunistically grows baseHeight before returning, preventing stale state if user types immediately after rotation. - Comment cleanup: dropped misleading BFCache reference (refs survive BFCache restore — poisoning only occurs on autofocus-race-during-mount). Shared event constant (new: src/config/customEvents.ts): - Single source of truth for mobile-nav-messages-tap event name. Both dispatcher and listener import from here to prevent typo drift. Event listener hygiene (DMChatSection.tsx + MobileBottomNav.tsx): - Drop the `if (!isMobile) return` gate and [isMobile] dep from the sidebar- reopen listener. setSidebarOpen(true) on desktop is inert (sidebar is lg:relative lg:translate-x-0 regardless of isOpen), and the dep caused listener churn on every viewport crossing 1024px. - Wrap dispatchEvent in queueMicrotask so the event fires AFTER React commits the route change from tapping Messages on /home. Previously the sync event was lost because DMChatSection hadn't mounted yet; the didInitSidebarRef one-shot salvaged the case — now both mechanisms can stand independently. HMR cleanup (useMobileNav.ts): - Added import.meta.hot.dispose to detach the MediaQueryList listener and clear module state on module reload. Prevents listener accumulation during long dev sessions with frequent edits.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a dedicated mobile layout for sphere. The existing desktop-metaphor UI (TabBar, floating chat bubbles, desktop icon grid) didn't work on phones — DM sidebar was hidden by default, MiniChatBubbles are
hidden md:flex(invisible on phones), Apps button was a confusing toggle. This PR adds a fixed bottom navigation bar with direct access to Home, Messages, and Wallet.Architecture
The bottom nav drives existing desktop state (
useDesktopState) instead of mounting parallel mobile views — no duplicate component instances, no divergent state.DMChatSectionauto-opens its sidebar on mobile when no conversation is selected so tapping Messages lands users on the conversation list.Changes
New components
src/components/mobile/MobileBottomNav.tsx— 3-tab fixed bottom nav with unread badgesrc/hooks/useMobileNav.ts— minimal{isMobile}hooksrc/config/customEvents.ts— shared custom event constantsModified
src/components/layout/DashboardLayout.tsx— renders bottom nav, fullscreen-escape on desktop→mobile transitionsrc/components/desktop/DesktopLayout.tsx— TabBar hidden on mobile (hidden lg:block), rounded-2xl scoped to lg+src/components/chat/dm/DMChatSection.tsx— sidebar auto-open, event listener for "tap Messages while already on DM"src/components/chat/dm/DMChatInput.tsx— removed redundant safe-area padding (outer layout handles it)src/hooks/useUIState.ts— mobile fullscreen guard (prevents unexit-able state)src/hooks/useDesktopState.ts—getDefaultState()function (defers matchMedia from module load), skips saveState when unchangedsrc/hooks/useVisualViewport.ts— keyboard detection with orientation-aware long-axis reference, self-heal via 70% thresholdsrc/index.css—--mobile-nav-heightCSS var,.pb-mobile-navutility, safe-area fallback 16pxHardening
Went through 5 rounds of adversarial review (steelman). Fixed 20+ issues including:
Scope
No telco/WebRTC code. This PR contains only mobile layout fixes. Any `__telco:` messages visible in chat are residual data from earlier testing on a separate branch — they'll be filtered by telco work when that ships.
Test plan