- Add complete docs/systems/ reference (18 system docs) - Add federation relay status doc and prior spec/plan docs - Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md) - CLAUDE.md updates - Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
21 KiB
Mobile & Responsive UI System
Source files:
packages/web/src/components/layout/MobileShell.tsx— Root mobile container: three tabs, screen stack, swipe gesture, browser history syncpackages/web/src/components/layout/MobileScreenStack.tsx— Push/pop animation state machine with CSS slide transitionspackages/web/src/components/layout/MobileBottomNav.tsx— Tab bar with unread badge counts, hidden when stack non-emptypackages/web/src/components/layout/MobileNav.tsx— Legacy hamburger menu (pre-MobileShell), renders only whenisMobiletruepackages/web/src/components/layout/MobileScreenHeader.tsx— Reusable back-arrow header for pushed screenspackages/web/src/components/layout/MobileChatScreen.tsx— Channel/DM chat view with MessageList, MessageInput, TypingIndicatorpackages/web/src/components/layout/MobileDmsScreen.tsx— DM list with online friends row, unread indicators, FAB for new DMpackages/web/src/components/layout/MobileSpacesScreen.tsx— Space strip + channel list (split-pane), folder support, voice user rowspackages/web/src/components/layout/MobileYouScreen.tsx— User profile card, action rows, logoutpackages/web/src/components/layout/MobileSettingsScreen.tsx— Settings hub and direct-panel rendering viainitialPanelproppackages/web/src/components/layout/MobileInstancePanel.tsx— Admin-only instance settings hub (General, Streaming, Storage, Users)packages/web/src/components/layout/MobileMembersScreen.tsx— Space member list grouped by role, with activity cardspackages/web/src/components/layout/MobileVoiceFullScreen.tsx— Full-screen voice call view with participant grid and control barpackages/web/src/components/layout/MobileVoiceMiniBar.tsx— Persistent mini-bar overlay during voice callspackages/web/src/components/layout/MobileFolderSheet.tsx— Bottom sheet for space folder contents, rename, color, ungrouppackages/web/src/hooks/useSwipeGesture.ts— Edge swipe-back touch gesture hookpackages/web/src/stores/uiStore.ts— Mobile navigation state (mobileScreen, mobileStack, push/pop actions)
Cross-references:
- Surface/glass tiers, animations, input classes: see
docs/systems/design-system.md - Voice call state machine, LiveKit integration: see
docs/systems/voice.md - Desktop three-column layout (AppLayout): see
docs/systems/design-system.md
Responsive Breakpoint
Detection is in AppLayout.tsx:
const checkMobile = () => setIsMobile(window.innerWidth < 768);
// Called on mount + resize listener
| Breakpoint | Value | Layout |
|---|---|---|
| Desktop | >= 768px |
AppLayout three-column grid (sidebar + chat + member list) |
| Mobile | < 768px |
MobileShell (tab bar + screen stack) |
AppLayout conditionally renders <MobileShell /> when isMobile === true. Modals render globally in both modes.
Desktop-to-Mobile Transition (uiStore:setIsMobile)
| Direction | State changes |
|---|---|
To mobile (isMobile: true) |
sidebarOpen: false, memberListOpen: false |
To desktop (isMobile: false) |
sidebarOpen: true, mobileScreen: 'spaces', mobileStack: [] (memberListOpen retains its persisted value) |
The setIsMobile function is a no-op if the value hasn't changed (prev === isMobile guard).
Architecture Overview
MobileShell (100dvh flex column)
+-- MobileScreenStack (flex-1, relative, overflow-hidden)
| +-- Root screen (spaces | dms | you) — always rendered, visibility-hidden when covered
| +-- Stacked screens (absolute inset-0, bg-surface-base, z-10)
+-- MobileVoiceMiniBar (conditional: when currentVoiceChannelId && voice-full not on top)
+-- MobileBottomNav (glass-bubble tab bar, hidden when stack non-empty)
Mobile Navigation State (uiStore)
Data Types
interface MobileStackEntry {
screen: string; // Screen key from screenMap
params?: Record<string, string>; // e.g., { channelId, spaceId }
}
// State
mobileScreen: 'spaces' | 'dms' | 'you'; // Active root tab
mobileStack: MobileStackEntry[]; // Push/pop stack
Actions
| Action | Behavior |
|---|---|
setMobileTab(tab) |
Sets mobileScreen to tab, clears mobileStack to [] |
pushMobileScreen(screen, params?) |
Appends entry to mobileStack, calls history.pushState({ mobileScreen: screen }, '') |
popMobileScreen() |
Removes last entry from mobileStack (no-op if empty). Does NOT call history.back() |
Browser History Integration
pushMobileScreen calls history.pushState to add a browser history entry. MobileShell listens for popstate events:
// MobileShell.tsx
useEffect(() => {
const handlePopState = () => {
if (useUIStore.getState().mobileStack.length > 0) {
popMobileScreen();
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [popMobileScreen]);
This means the hardware/browser back button pops the mobile screen stack. popMobileScreen intentionally does not call history.back() to avoid infinite loops when triggered by the popstate handler.
Deep Link Reconstruction
On mount, MobileShell checks location.pathname for /channels/:spaceId/:channelId and pushes a channel-chat screen if the stack is empty:
useEffect(() => {
const path = location.pathname;
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
if (match && mobileStack.length === 0) {
pushMobileScreen('channel-chat', { channelId, spaceId });
}
}, []); // Mount only
User Profile Mobile Override
uiStore:openUserProfile detects isMobile and pushes a user-profile screen instead of showing a positioned popout:
if (get().isMobile) {
set((state) => ({
mobileStack: [...state.mobileStack, { screen: 'user-profile', params: { userId: user.id } }],
}));
history.pushState({ mobileScreen: 'user-profile' }, '');
}
MobileScreenStack — Animation State Machine
File: MobileScreenStack.tsx
The stack manages CSS slide-in/slide-out animations using a dual-state approach: the canonical mobileStack (from uiStore) drives transitions, while renderStack (local state) controls what's actually rendered.
State
const [transitioning, setTransitioning] = useState<'push' | 'pop' | null>(null);
const [renderStack, setRenderStack] = useState(mobileStack);
const prevStackRef = useRef(mobileStack);
const animatingRef = useRef(false);
Transition Algorithm
The useEffect on mobileStack compares the new length to the previous length:
Push (newLen > prevLen):
- Set
renderStack = mobileStack(new screen enters the DOM) - Set
transitioning = 'push'(new screen positioned attranslateX(100%)— off-screen right) - Double
requestAnimationFrameensures the off-screen position is painted - Set
transitioning = null(CSS transition kicks in, slides screen totranslateX(0))
Pop (newLen < prevLen):
- Set
transitioning = 'pop'(top screen getstransition-transform duration-200 ease-out+translateX(100%)) - After 200ms timeout: set
renderStack = mobileStack(removed screen exits DOM),transitioning = null
Same length (replacement):
- Directly set
renderStack = mobileStack(no animation)
CSS Classes per Screen State
| Condition | Classes | Transform |
|---|---|---|
Top screen, transitioning === 'push' |
absolute inset-0 bg-surface-base z-10 |
translateX(100%) |
Top screen, transitioning === 'pop' |
... transition-transform duration-200 ease-out |
translateX(100%) |
| Top screen, settled (no transition) | ... transition-transform duration-200 ease-out |
(none, defaults to 0) |
| Non-top screen | absolute inset-0 bg-surface-base z-10 |
(none) |
Root Screen Visibility
The root screen (spaces/dms/you) is always rendered but has visibility: hidden when renderStack.length > 0. This avoids unmount/remount when returning to root.
Animation Timing
| Phase | Duration | Mechanism |
|---|---|---|
| Push: off-screen paint | ~2 frames (via double rAF) | requestAnimationFrame x2 |
| Push: slide in | 200ms | CSS transition-transform duration-200 ease-out |
| Pop: slide out | 200ms | CSS transition-transform duration-200 ease-out |
| Pop: DOM cleanup | 200ms | setTimeout(200) after which renderStack is updated |
MobileBottomNav — Tab Bar
File: MobileBottomNav.tsx
Visibility
Returns null when mobileStack.length > 0 — hidden whenever a pushed screen is active.
Tabs
| Tab | Badge Type | Badge Source |
|---|---|---|
| Spaces | Dot (red) | unreadChannels has any non-voice channel |
| DMs | Numeric count | Count of DM channels where lastMessage.id > readStates[dmId] |
| You | Dot (red) | Pending incoming friend requests (status === 'pending' and fromId !== authUser.id) |
Badge caps at 99+ for numeric badges.
Tab Tap Behavior
| Tab | Navigation |
|---|---|
| Spaces | Navigates to last known space route, or / |
| DMs | Navigates to /channels/@me |
| You | No navigation (stays on current route) |
All tabs call setMobileTab(tab) which clears the mobile stack.
Styling
- Container:
glass-bubblesurface tier - Height:
calc(56px + env(safe-area-inset-bottom)) - Active tab:
text-accent-primary; Inactive:text-txt-secondary
Edge Swipe-Back Gesture (useSwipeGesture)
File: hooks/useSwipeGesture.ts
Parameters
| Param | Default | Description |
|---|---|---|
onSwipeRight |
— | Callback fired on successful swipe |
edgeThreshold |
20 (px) |
Touch must start within this distance from the left edge |
swipeThreshold |
50 (px) |
Horizontal movement required to trigger |
enabled |
true |
Disables all event listeners when false |
Algorithm
touchstart: Iftouch.clientX <= 20, record start positiontouchmove: If vertical movement exceeds horizontal (and not already swiping), cancel. If horizontal dx > 50px, set swiping flag andpreventDefault()touchend/touchcancel: If swiping flag is set, fireonSwipeRight
Usage in MobileShell
useSwipeGesture({
onSwipeRight: () => {
if (mobileStack.length > 0) popMobileScreen();
},
enabled: mobileStack.length > 0,
});
Only active when there are pushed screens to pop. Document-level event listeners are added/removed based on enabled.
Event options: touchstart is { passive: true }, touchmove is { passive: false } (allows preventDefault to block scroll during swipe).
Screen Map
MobileShell defines a screenMap that maps screen keys to render functions:
| Screen Key | Component | Params |
|---|---|---|
channel-chat |
MobileChatScreen |
{ channelId, spaceId } |
friends |
FriendsPage (with mobile prop) |
— |
settings |
MobileSettingsScreen |
— |
settings-account |
MobileSettingsScreen |
initialPanel="account" |
settings-voice |
MobileSettingsScreen |
initialPanel="voice" |
settings-privacy |
MobileSettingsScreen |
initialPanel="privacy" |
settings-connections |
MobileSettingsScreen |
initialPanel="connections" |
settings-instance |
MobileInstancePanel |
— |
settings-instance-general |
GeneralPanel (wrapped) |
— |
settings-instance-streaming |
StreamingPanel (wrapped) |
— |
settings-instance-storage |
StoragePanel (wrapped) |
— |
settings-instance-users |
UsersPanel (wrapped) |
— |
members |
MobileMembersScreen |
{ spaceId? } |
voice-full |
MobileVoiceFullScreen |
— |
explore |
ExplorePage |
— |
user-profile |
UserProfileModal |
{ userId } (opens modal via openModal('userProfile', ...)) |
Instance settings sub-panels (settings-instance-*) are wrapped inline with MobileScreenHeader + scrollable container + bg-surface-base.
Root Screens
MobileSpacesScreen
Split-pane layout: 60px glass-strip space strip on the left + channel list on the right.
Space strip features:
- Home/DMs button at top (navigates to DMs tab)
- Folder-aware layout via
spaceLayoutandfoldersfrom spaceStore - Unread pill indicator on left edge (8px dot for unread, 32px bar for selected)
- Federation badge on space icons (globe icon, amber dot if disconnected)
- Context menu: Invite, Create Folder, Move to Folder, Remove from Folder, Transfer Ownership, Leave
- Add Space button at bottom (opens bottom sheet: Create / Join / Explore)
Channel list features:
- Channels grouped by categories (collapsible)
- Uncategorized channels rendered first
- Text channels:
#prefix, unread dot, selected highlight - Voice channels: speaker icon, inline
VoiceUserRowfor connected users with context menus - Voice channel tap opens
MobileVoiceJoinSheet(not direct join) - Text channel tap: navigates via router + pushes
channel-chatscreen - Channel/category context menus for management (guarded by
MANAGE_CHANNELSpermission)
Layout resolution:
spaceLayoutarray items can be{ t: 's', id }(space) or{ t: 'f', id }(folder)- Spaces not in the layout are appended at the end
- Folder items render as folder icon buttons that open
MobileFolderSheet
MobileDmsScreen
- Header: "Messages" title + "Friends" button
- Online friends activity row (horizontal scroll, shows avatar + status dot)
- DM list sorted by last message time (newest first)
- Each DM row: avatar, name, message preview, timestamp, unread dot
- Group DMs: group icon instead of avatar, context menu with "Leave Group"
- Federated users:
@domainsubtitle below username - Empty state: sleeping mascot
- FAB: New DM button (opens
newDmmodal), positionedbottom-20 right-4
MobileYouScreen
- Settings gear in header (pushes
settings) - Profile card: banner/accent background, avatar (-10 overlap), display name, username, custom status, bio
- Action rows (each pushes a settings sub-screen): Edit Profile, Friends, Connections, Voice & Audio
- Log Out button with
ConfirmDialog
Pushed Screen Components
MobileChatScreen
Params: { channelId, spaceId }
- Loads messages and sets current channel on mount via
useChatStore - Resolves channel name: DM names from member list (group: comma-separated), space channels by
#name - Custom header with back button + channel name + members button (space channels only)
- Members button pushes
membersscreen (not shown for DMs) - Renders
MessageList,TypingIndicator,MessageInput
MobileSettingsScreen
Two modes controlled by initialPanel prop:
- Hub mode (
initialPanelundefined): List of setting sections (Account, Voice & Audio, Privacy, Connections, Instance for admins). Each pushessettings-{id}. - Direct panel mode (
initialPanelset): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
MobileInstancePanel
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists four sub-sections (General, Streaming, Storage, Users), each pushing settings-instance-{id}.
MobileMembersScreen
Params: { spaceId? } (falls back to currentSpaceId)
- Groups online members by their highest-positioned role
- Owner gets special
__owner__group (position Infinity) - Offline members in separate section
- Each member row: avatar with status, role-colored name, federated domain, activity card
- Tap opens
user-profilescreen
Member group resolution (getMemberGroup):
- Owner:
{ key: '__owner__', label: 'OWNER', position: Infinity } - Has roles: top role by position
{ key: roleId, label: ROLE_NAME, position } - No roles:
{ key: '__online__', label: 'ONLINE', position: -1 }
MobileScreenHeader
Reusable header component used by MobileInstancePanel, MobileMembersScreen, and inline in screenMap wrappers.
interface MobileScreenHeaderProps {
title: string;
rightActions?: React.ReactNode;
}
- Height: 48px (
h-12) - Back button calls
popMobileScreen() - Bottom border:
border-border-soft - Background:
bg-surface-base
Voice Overlay
MobileVoiceMiniBar
File: MobileVoiceMiniBar.tsx
Visibility rules:
- Shown when
currentVoiceChannelIdis truthy - Hidden when
voice-fullis the top screen inmobileStack
Layout: glass-bubble container, mx-2 mb-1 rounded-2xl. Positioned between MobileScreenStack and MobileBottomNav in the DOM.
Content:
- Left: mint circle icon + channel name + participant count (tap expands to
voice-full) - Right: mute toggle, deafen toggle, disconnect button
- Quick controls use
e.stopPropagation()to prevent expanding on control taps
Disconnect logic: Handles both DM calls (dm_call_end WS event) and space voice channels (voice_leave WS event), calls disconnectFn, clears activeDmCall.
MobileVoiceFullScreen
File: MobileVoiceFullScreen.tsx
Header: Collapse chevron (down arrow, pops screen), channel name, space name subtitle, participant count, members button (space channels only).
Participant grid:
grid-cols-1for 1-2 participants,grid-cols-2for 3+- Avatar size: 80px for 1-2 participants, 56px for 3+
- Mute/deafen badge overlay on avatar (bottom-right, rose circle with icon)
- Shows self-mute, space mute, permission mute, self-deafen, space deafen
- Context menu on other participants: voice mod items, local mute checkbox, volume slider
Control bar: glass-bubble container with safe area padding.
| Button | State Colors |
|---|---|
| Mute | Active: bg-accent-rose/20 text-accent-rose, Inactive: bg-surface-elevated text-txt-primary |
| Deafen | Same as mute |
| Camera | Active: bg-accent-mint/20 text-accent-mint, Inactive: same |
| Screen share | Same as camera |
| Disconnect | Always bg-accent-rose text-white |
Disconnect: Same logic as mini-bar (handles DM calls and space voice, calls disconnectFn, pops screen).
Guard: If currentVoiceChannelId is falsy, calls popMobileScreen() and returns null.
MobileFolderSheet
File: MobileFolderSheet.tsx
Bottom sheet for viewing and managing space folders.
Presentation:
- Fixed overlay:
z-[300]backdrop +z-[301]sheet - Glass:
glass-modalsurface tier - Animation:
animate-slide-up-sheet(200ms ease-out translateY) - Max height:
60vh - Drag handle: 10x1 rounded pill
Props:
interface MobileFolderSheetProps {
folder: SpaceFolder;
onClose: () => void;
onSelectSpace: (spaceId: string) => void;
onUpdateFolder: (folderId: string, updates: { name?: string | null; color?: string | null }) => void;
onUngroup: (folderId: string) => void;
}
Features:
- Folder header with color swatch, name (inline editable), space count
- Context menu: Rename, Color picker (7 accent colors + clear), Ungroup (danger)
- Space list with gradient/icon thumbnails, tap selects space and closes sheet
Folder colors:
| Name | Value |
|---|---|
| Mint | rgb(var(--accent-mint)) |
| Peach | rgb(var(--accent-peach)) |
| Lavender | rgb(var(--accent-lavender)) |
| Sky | rgb(var(--accent-sky)) |
| Amber | rgb(var(--accent-amber)) |
| Rose | rgb(var(--accent-rose)) |
| Coral | rgb(var(--accent-coral)) |
MobileNav (Legacy)
File: MobileNav.tsx
A hamburger-menu component that predates MobileShell. It renders a fixed-position toggle button (z-[120]) and a backdrop overlay (z-[35]). Only renders when isMobile === true (via if (!isMobile) return null).
This component provides sidebar toggle functionality for contexts where MobileShell is not the active layout. It is separate from the MobileShell tab-based navigation.
Safe Area Handling
Several mobile components respect the iOS safe area inset:
| Component | CSS |
|---|---|
| MobileBottomNav | paddingBottom: env(safe-area-inset-bottom), height includes inset |
| MobileVoiceFullScreen control bar | marginBottom: calc(0.5rem + env(safe-area-inset-bottom)) |
| MobileFolderSheet | paddingBottom: env(safe-area-inset-bottom) |
| MobileSpacesScreen add sheet | paddingBottom: env(safe-area-inset-bottom) |
The root MobileShell uses height: 100dvh (dynamic viewport height) to account for mobile browser chrome.
Z-Index Layers
| Layer | Z-Index | Component |
|---|---|---|
| Stacked screens | z-10 |
MobileScreenStack pushed screens |
| MobileNav backdrop | z-[35] |
MobileNav sidebar overlay |
| MobileNav hamburger | z-[120] |
MobileNav toggle button |
| DMs FAB | z-20 |
MobileDmsScreen new DM button |
| Bottom sheets (backdrop) | z-[300] |
MobileFolderSheet, Add Space sheet, ContextMenu |
| Bottom sheets (content) | z-[301] |
MobileFolderSheet, Add Space sheet, ContextMenu |
LocalStorage Persistence
The uiStore uses zustand/persist with partialize:
partialize: (state) => ({
memberListOpen: state.memberListOpen,
lastChannelPerSpace: state.lastChannelPerSpace,
})
Only memberListOpen and lastChannelPerSpace persist. Mobile navigation state (mobileScreen, mobileStack) is ephemeral and resets on page reload.
lastChannelPerSpace is used by MobileBottomNav to navigate to the last-viewed channel when the Spaces tab is tapped, and by MobileSpacesScreen when a text channel is opened (via setLastChannel).