- {/* Space sidebar - always visible on desktop, toggled on mobile */}
+ {/* Space sidebar - always visible on desktop */}
@@ -304,12 +334,12 @@ export function AppLayout() {
{/* User Profile Popout */}
{userProfilePopout.user && userProfilePopout.position && (
<>
-
-
diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx
new file mode 100644
index 00000000..94f380db
--- /dev/null
+++ b/packages/web/src/components/layout/MobileShell.tsx
@@ -0,0 +1,94 @@
+import React, { useEffect } from 'react';
+import { useUIStore } from '../../stores/uiStore';
+import { useVoiceStore } from '../../stores/voiceStore';
+import { useLocation } from 'react-router-dom';
+import { MobileScreenStack } from './MobileScreenStack';
+import { MobileBottomNav } from './MobileBottomNav';
+
+// Screen components — imported as they are created in subsequent tasks.
+// Inline placeholders are used until the real components exist.
+const PlaceholderScreen = ({ label }: { label: string }) => (
+
+ {label}
+
+);
+
+// These will be replaced with real imports as each task is completed:
+const MobileSpacesScreen = () =>
;
+const MobileDmsScreen = () =>
;
+const MobileYouScreen = () =>
;
+const MobileChatScreen = ({ params: _params }: { params?: Record
}) => ;
+const MobileSettingsScreen = ({ initialPanel: _p }: { initialPanel?: string }) => ;
+const MobileVoiceMiniBar = () => null;
+const MobileVoiceFullScreen = () => ;
+
+const screenMap: Record) => React.ReactNode> = {
+ 'channel-chat': (params) => ,
+ 'friends': () => ,
+ 'settings': () => ,
+ 'settings-account': () => ,
+ 'settings-voice': () => ,
+ 'settings-privacy': () => ,
+ 'settings-connections': () => ,
+ 'settings-instance': () => ,
+ 'members': () => ,
+ 'voice-full': () => ,
+ 'explore': () => ,
+ 'user-profile': () => ,
+};
+
+export function MobileShell() {
+ const mobileScreen = useUIStore((s) => s.mobileScreen);
+ const popMobileScreen = useUIStore((s) => s.popMobileScreen);
+ const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
+ const mobileStack = useUIStore((s) => s.mobileStack);
+ const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
+ const location = useLocation();
+
+ // Reconstruct mobile stack from URL on mount (deep link / refresh support)
+ useEffect(() => {
+ const path = location.pathname;
+ const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
+ if (match && mobileStack.length === 0) {
+ const [, spaceId, channelId] = match;
+ if (spaceId === '@me') {
+ pushMobileScreen('channel-chat', { channelId, spaceId: '@me' });
+ } else {
+ pushMobileScreen('channel-chat', { channelId, spaceId });
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Only on mount
+
+ // Sync browser back button with mobile stack
+ useEffect(() => {
+ const handlePopState = () => {
+ if (useUIStore.getState().mobileStack.length > 0) {
+ popMobileScreen();
+ }
+ };
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [popMobileScreen]);
+
+ const rootScreens: Record = {
+ spaces: ,
+ dms: ,
+ you: ,
+ };
+
+ return (
+
+
+
+ {/* Voice mini-bar — shown when in a voice call */}
+ {currentVoiceChannelId && }
+
+ {/* Bottom nav — MobileBottomNav hides itself when stack is non-empty */}
+
+
+ );
+}