Skip to content

fix(mobile): dedicated mobile layout with bottom navigation#301

Open
vrogojin wants to merge 7 commits intomainfrom
fix/mobile-layout
Open

fix(mobile): dedicated mobile layout with bottom navigation#301
vrogojin wants to merge 7 commits intomainfrom
fix/mobile-layout

Conversation

@vrogojin
Copy link
Copy Markdown
Contributor

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. DMChatSection auto-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 badge
  • src/hooks/useMobileNav.ts — minimal {isMobile} hook
  • src/config/customEvents.ts — shared custom event constants

Modified

  • src/components/layout/DashboardLayout.tsx — renders bottom nav, fullscreen-escape on desktop→mobile transition
  • src/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.tsgetDefaultState() function (defers matchMedia from module load), skips saveState when unchanged
  • src/hooks/useVisualViewport.ts — keyboard detection with orientation-aware long-axis reference, self-heal via 70% threshold
  • src/index.css--mobile-nav-height CSS var, .pb-mobile-nav utility, safe-area fallback 16px

Hardening

Went through 5 rounds of adversarial review (steelman). Fixed 20+ issues including:

  • Dual useChat/WalletPanel/DesktopShortcuts instances (eliminated by state-driven approach)
  • Home tab toggle bug (idempotent handlers)
  • Messages tab stale-highlight on non-DM routes
  • Fullscreen trap on desktop→mobile resize
  • Keyboard detection on Android with `interactive-widget=resizes-content`
  • Orientation-change false positives
  • MediaQueryList listener leaks on HMR
  • Safe-area clearance on devices without `env()` support
  • Fractional-zoom breakpoint gap (1023.98px)

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

  • Lint clean
  • 37/37 unit tests pass
  • TypeScript: no errors
  • Docker build succeeds
  • Deployed to https://sphere-telco-test.dyndns.org for real-device testing
  • Manual test on Android Chrome — bottom nav visible, tabs functional, DM sidebar shows on Messages tap
  • Manual test on desktop — no regression (TabBar + existing layout unchanged)
  • Manual test viewport resize — no flicker or state loss

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant