Brings the parked mobile voice + screenshare polish from wip/mobile-polish
(commit dbb9b2c) into main. Conflicts in MainContent.tsx and mobile-ui.md
expected per the WIP commit message — resolved manually.
62 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, Registration, Federation, Streaming, Storage, Users; surfaces federation approval-count badge)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/hooks/useDragToClose.ts— Bottom-sheet drag-down-to-dismiss gesture hook (shared byInputPopover.MobileSheet,MobileVoiceJoinSheet,MobileFolderSheet)packages/web/src/hooks/useVisualViewportInset.ts— Returns the bottom inset that floating overlays must use to sit above the iOS soft keyboard (when open) or above the home-indicator safe area (when closed). Used byMessageInputfor the floating composer-bubble'sbottomvalue.packages/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
MobileShell watches location.pathname for /channels/:spaceId/:channelId and pushes a channel-chat screen when the URL changes to a channel route — both on mount (deep link / refresh) and on subsequent programmatic navigations (e.g. SpaceInviteCard Join button, joinByCode flows, any useNavigate(...) call).
useEffect(() => {
const path = location.pathname;
const match = path.match(/^\/channels\/([^/]+)\/([^/]+)$/);
if (!match) return;
const spaceId = match[1] ?? '';
const channelId = match[2] ?? '';
const normalizedSpaceId = spaceId === '@me' ? '@me' : spaceId;
// Idempotency guard — read stack imperatively to avoid re-firing on stack changes
const currentStack = useUIStore.getState().mobileStack;
const top = currentStack[currentStack.length - 1];
if (
top &&
top.screen === 'channel-chat' &&
top.params?.channelId === channelId &&
top.params?.spaceId === normalizedSpaceId
) {
return;
}
pushMobileScreen('channel-chat', { channelId, spaceId: normalizedSpaceId });
}, [location.pathname, pushMobileScreen]);
Why these dependencies and the idempotency guard exist:
- The dep array intentionally excludes
mobileStack. The current stack is read imperatively viauseUIStore.getState()so that pushing an unrelated screen (e.g.settings) does not re-trigger this effect — otherwise we would re-pushchannel-chaton top of every newly pushed screen because pathname is still/channels/.... - The guard catches the common in-app case where
MobileSpacesScreencalls bothpushMobileScreen('channel-chat', …)ANDnavigate('/channels/…'). The push happens first (no pathname change sincepushMobileScreencallshistory.pushStatewith no URL), thennavigatemutates pathname → this effect re-runs → top already matches → skip. - The guard also catches the popstate path: browser back pops both the history entry and the mobile stack; if the new pathname is a channel route already represented by the new top entry, we skip.
In-app navigation that lands on a different channel (e.g. tapping a SpaceInviteCard Join button while inside another chat) stacks the new channel-chat on top so back returns to the originating chat.
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 /channels/<currentSpaceId ?? lastSelectedSpaceId> if either is set, otherwise / (which redirects to /channels/@me) |
| DMs | Navigates to /channels/@me |
| You | No navigation (stays on current route) |
All tabs call setMobileTab(tab) which clears the mobile stack.
The Spaces tab prefers useSpaceStore.getState().currentSpaceId — the canonical "currently selected space" — and falls back to lastSelectedSpaceId, a sticky memory in useSpaceStore that survives @me navigation. currentSpaceId is the canonical answer when available (URL routing, the space strip, SpaceInviteCard joins all set it), but AppLayout's URL effect clears currentSpaceId to null whenever the URL is /channels/@me. Without the sticky fallback, returning to Spaces after a DMs/Friends/Settings detour would have nothing to anchor to and MobileSpacesScreen's auto-select would fall back to spaces[0]. The previous Object.keys(lastChannelPerSpace)[0] approach was wrong for a different reason (it returned the first-inserted key, locking the tab to whichever space the user opened first in their session); lastChannelPerSpace is still used by MobileSpacesScreen.setLastChannel for the per-space last-channel-jump-on-channel-tap feature — that remains a separate concern.
MobileSpacesScreen mirrors the same fallback at mount: useState(currentSpaceId ?? lastSelectedSpaceId). After a DM detour the screen remounts with currentSpaceId === null (cleared by AppLayout) but the sticky memory still resolves to the previously-selected space. The local setCurrentSpace(selectedSpaceId) effect then restores currentSpaceId to that value, so the rest of the app sees a consistent selection.
lastSelectedSpaceId lifecycle (defined in useSpaceStore):
- Updated on every
setCurrentSpace(non-null)call and onloadSpaceDetailsuccess. - NOT cleared by
setCurrentSpace(null)— that's the whole point. - Cleared to null only when the remembered space is actually removed:
deleteSpace,leaveSpace,removeSpace(kicked / WS event),removeInstanceSpaces(instance disconnect/removal),reset(logout). - Ephemeral — not persisted to localStorage. On page reload the URL drives initial state; the sticky memory only matters within a session, between tab cycles.
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-keybinds |
MobileSettingsScreen |
initialPanel="keybinds" (Electron-only entry; map row always present) |
settings-desktop |
MobileSettingsScreen |
initialPanel="desktop" (Electron-only entry; map row always present) |
settings-instance |
MobileInstancePanel |
— |
settings-instance-general |
GeneralPanel (wrapped) |
— |
settings-instance-registration |
RegistrationPanel (wrapped) |
— |
settings-instance-federation |
FederationPanel (wrapped, forwards onApprovalCountChange → uiStore.setFederationApprovalCount) |
— |
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) - Loading skeleton: while
useSpaceStore.loadingSpaceId === selectedSpaceId, the channel-list area renders a shimmer skeleton (uncategorized rows + category header + categorized rows). Gated throughuseDelayedLoadingso cached/fast loads don't flash the placeholder. Mirrors desktopChannelSidebar'sshowChannelSkeleton. Skeleton row geometry matches the real channel rows (px-3 py-2withw-4 h-4icon → ~36px row). - Empty-state mascot — settle gate: the "No channels yet." mascot must NOT render during the pre-skeleton load window (the < 200 ms threshold of
useDelayedLoading). Without a settle gate, every space switch flashes the mascot for ~50–200 ms becausestate.channelsonly ever holds the most-recently loaded space's channels —spaceChannelsfilters to[]immediately on selection change whileloadSpaceDetailis still in flight. The mascot is gated onisSpaceSettledEmpty = !isLoadingSelectedSpace && loadedSpaceIds.has(selectedSpaceId) && spaceChannels.length === 0.loadedSpaceIdsis aSet<string>onuseSpaceStore, populated only on successfulloadSpaceDetailcompletion (not on failed loads), and pruned ondeleteSpace/leaveSpace/removeSpace/removeInstanceSpaces/reset. Render order is therefore: skeleton (loading, > threshold) → blank (loading, < threshold) → mascot (settled empty) → real channel list. DesktopChannelSidebarhas no empty-state branch, so this asymmetry is mobile-only.
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: rendered via the shared
<AvatarStack>(size 40,border="channel",iconUrl={dm.icon}) so single-other-member groups, 2-member overlap, and 3+ grids match the desktop sidebar; group name usesdm.name ?? otherMembers.map(displayName).join(', '). A federation globe renders next to the name when any group member is federated. Context menu: "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 & Video
- 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/group-info button
- Members button shows for space channels AND group DMs; hidden for 1-on-1 DMs (no roster). Space channels push the
membersscreen; group DMs push thegroup-dm-infoscreen so the user lands on the full info + management surface (MobileGroupDmInfo). - Renders
MessageList,TypingIndicator,MessageInput
MobileSettingsScreen
Two modes controlled by initialPanel prop:
- Hub mode (
initialPanelundefined): List of setting sections (Account, Voice & Video, Privacy, Connections, Keybinds + Desktop when in Electron, Instance for admins). Each pushessettings-{id}. - Direct panel mode (
initialPanelset): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel, KeybindsPanel, DesktopPanel) directly with a back header.
Electron-only entries. The Keybinds and Desktop sections appear in the hub list only when isElectron() === true (mirrors the desktop UserSettings modal's gate on DesktopPanel). Rationale:
DesktopPanelexposes auto-launch, app-version + update check, and "Change Instance" — all of which callwindow.backspace.*IPC and are meaningless on web/iOS PWA.KeybindsPanel's value comes from the desktop app'suiohook-napi-backed global keybind manager. The web fallback (only-when-tab-focused, no global hooks, no recording flow on touch keyboards) has no useful surface for a phone-shaped viewport. Showing the panel anyway would mislead a mobile-web user into recording a binding that can never fire.
Both panels are mobile-fit at 360-390px viewports (single-column rows with flex justify-between, min-w-0 on labels, small tap-target buttons). The gate is therefore a list-visibility decision, not a layout decision — once a desktop user happens to be on a narrow viewport (split-window, dock, etc.), the panels render correctly.
MobileInstancePanel
Admin-only instance settings hub. Pre-fetches instance settings and streaming limits on mount. Lists six sub-sections (General, Registration, Federation, Streaming, Storage, Users), each pushing settings-instance-{id}. Mirrors the desktop InstancePanel exactly.
The Federation row carries a numeric badge driven by uiStore.federationApprovalCount (capped at 99+, styled like the unread-DM badge in MobileBottomNav). The badge source has two paths:
- Initial / standalone fetch:
MobileInstancePanelcallsapi.federation.approvalRequests()on mount and re-fetches whenonFederationPeersChangedfires (mirrors whatFederationPanel's internalPendingApprovalscomponent does). This makes the badge accurate before the admin enters the Federation panel. - Live updates while inside the panel: the wrapper around
FederationPanelinMobileShell.tsxforwards the panel'sonApprovalCountChangecallback intouiStore.setFederationApprovalCount. As the admin approves/denies requests inside the panel, the count drops and the badge in the parent hub stays in sync.
MobileInstancePanel is rendered behind a top-level isAdmin guard from MobileSettingsScreen — non-admin users cannot reach it.
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 }
Loading skeleton: while useSpaceStore.loadingSpaceId === spaceId (the same flag that gates MobileSpacesScreen's channel-list skeleton — loadSpaceDetail populates members alongside channels), the screen renders a shimmer skeleton (two role-section headers + circular avatar placeholders + name bars). Gated through useDelayedLoading so cached/fast loads don't flash the placeholder. Mirrors desktop MemberSidebar's showMemberSkeleton. Skeleton row geometry matches the real member rows (gap-2.5 px-2 py-2.5 with w-9 h-9 avatar → ~52px row).
MobileGroupDmInfo
Params: { channelId }
Pushed by MobileChatScreen's members button when the active channel is a group DM. Mirrors MobileMembersScreen's scroll-body geometry and condenses the desktop GroupDmSettings modal + DmRosterPanel into a single column.
Layout:
- Header (
MobileScreenHeader): "Group Info" + back button. - Hero:
<AvatarStack size=80 border="modal" iconUrl={dm.icon}>(or icon when set), large group name (dm.name ?? comma-joined fallback),N members, and an Edit button (owner-only) that toggles inline edit mode — name becomes a text input; tapping the icon opens the existingImageCropModal(also used byRegisterPageandCreateSpace). Save/Cancel bar appears at the bottom of the screen, positioned viauseVisualViewportInsetso it rides above the iOS keyboard. Upload defers to Save (no orphan uploads). - Actions row: Add Member (any-member; opens
AddDmMemberModal). - Members list: OWNER (crown badge), ONLINE, OFFLINE (dimmed). Uses the shared
DmMemberRowcomponent. Tap →user-profilescreen. Long-press OR always-visible kebab → context menu (Transfer OwnershipandRemove from Groupare owner-only and hidden on self;Remove Friendshown when row user is a friend AND not self). The federation globe renders without a long-press tooltip — the per-row@domainsubtitle already surfaces federated identity, so a tooltip would be redundant. - Destructive footer: "Leave Group" (red).
Owner-only API calls (updateMetadata, kickMember, transferOwnership) route through getApiForOrigin(getOwnerInstanceForDm(channelId)) — see docs/systems/dm-system.md "Owner-Only Routing Helper" for why this is distinct from getChannelOrigin.
MobileScreenHeader
Reusable header component used by MobileInstancePanel, MobileMembersScreen, MobileSettingsScreen, 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
Canonical pattern — TransferIndicator in rightActions: every settings/instance screen mounts <TransferIndicator /> via the rightActions slot so an in-flight profile/banner upload (or any transfer initiated before navigating into settings) remains visible and controllable from the screen the user is currently on. Wired in: MobileSettingsScreen (hub + each direct panel mode), MobileInstancePanel, all six settings-instance-* wrappers in MobileShell.tsx (general / registration / federation / streaming / storage / users). The indicator is idle-cheap — a single Map subscription + small icon button when no transfers are active — so mounting it on every settings screen has no measurable performance cost. See docs/systems/uploads.md for the transfer-chrome surface inventory.
MobileChatScreen uses its own inline header (not MobileScreenHeader) and mounts TransferIndicator directly. The dropdown panel renders below the trigger via absolute right-0 top-full mt-2 and is width-capped at min(300px, calc(100vw - 16px)) to avoid clipping on narrow viewports. Click-outside dismissal listens to both mousedown and touchstart so iOS Safari closes the tray on a single tap.
Voice Overlay
Mobile is the sole owner of voice overlay chrome. <PictureInPicture /> is a desktop-only component; AppLayout's mobile branch does NOT mount it. The mobile equivalents are MobileVoiceMiniBar (always-visible call-active overlay between the screen stack and bottom nav) and MobileVoiceFullScreen (pushed-screen full takeover). Mounting PiP on mobile would render its 320×180 floating box on top of the mobile shell whenever currentVoiceChannelId was set but voice-full was not on the stack — the symptom that triggered this split was a "PiP-style grey view" appearing immediately after MobileVoiceJoinSheet's Join button (compounded by a misnamed screen key — see "Voice Join Flow" below).
Voice Join Flow (Mobile)
MobileSpacesScreen.handleVoiceJoin is the single entry point on mobile:
- Apply pre-mute if requested (
voiceStore.setMuted(true)). - Call
joinVoiceChannel(channelId, connectFn)(which sendsvoice_joinWS, gets a LiveKit token, and connects the room). - Close the join sheet (
setVoiceJoinChannelId(null)). pushMobileScreen('voice-full')— must match the canonical key inMobileShell.screenMap. A historical bug passed'voice', which had no entry; the renderer returned null while the root screen sat undervisibility: hidden, making the (incorrectly mounted) desktop PiP the only visible UI. Both have been root-fixed.
The user lands directly in MobileVoiceFullScreen — there is no intermediate "connecting" screen. While LiveKit handshakes, the participant grid renders with whatever the WS voice_users map already contains (typically just the local user) and updates as remote participants arrive.
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: Renders <VoiceGrid participants={participants} /> from packages/web/src/components/voice/VoiceGrid.tsx — the same component desktop uses. This is the source of the camera + screen-share rendering pipeline; mobile has no separate tile components. Reusing VoiceGrid gives mobile feature parity with desktop for free:
- Camera tracks (
p.videoTrackfromParticipantInfo) attach to a<video>element via LiveKit'sTrack.attach(el)so the SFU adaptive-stream observer can downshift simulcast layers based on the tile's painted pixel size — automatically scaling quality down on phone-shaped tiles. - Local camera: the local participant's
videoTrackis attached identically, withmutedon the<video>element so the user's own camera doesn't echo through their speakers. (VoiceUsersetsmuted={isLocal}.) - Remote screen-share tracks render in their own
StreamTile(one extra tile per streaming participant) — the user must tap "Watch Stream" or focus the tile to subscribe; until then it's an avatar placeholder.setStreamSubscriptionand thestream_watchdata-channel protocol fire identically on mobile. - Tap-to-focus: tapping any tile sets
voiceStore.focusedParticipantIdand the grid switches into the focused-publisher layout (one large tile + bottom strip of others). This works through touch events without modification. - Mute / deafen / camera badges, speaking-ring, "(you)" suffix, context-menu (right-click on desktop, long-press on iOS — Safari fires
contextmenuon long-press), local-mute/volume sliders, watch/unwatch — all carry over.
Auto-focus on screen-share (mobile-only). When MobileVoiceFullScreen mounts (or while it's already mounted) and a screen-share publication appears, the screen sets focusedParticipantId to the first live ${identity}:stream tile so the user lands directly on the watchable stream. Two refs gate the behaviour:
userTouchedFocusRef— flipstruethe first timefocusedParticipantIdchanges to anything other than the auto-focused key, or tonullafter auto-focus had been set. Once flipped it stays flipped for the screen lifetime; auto-focus bails out. This means a user who explicitly dismisses focus via the Grid button never has it forced back on, even if a new screen-share starts.lastAutoFocusedKeyRef— records the key we last auto-focused so the user-interaction detection can distinguish "user picked a different tile" from "we just set it ourselves".
The unmount cleanup clears focusedParticipantId so re-entering the call screen is a clean slate. Desktop is unaffected — auto-focus lives in MobileVoiceFullScreen, not VoiceGrid.
Control bar: glass-bubble container with safe area padding. Five round buttons.
| Button | Action | State Colors |
|---|---|---|
| Mute | voiceStore.toggleMic |
Active: bg-accent-rose/20 text-accent-rose, Inactive: bg-surface-elevated text-txt-primary |
| Deafen | voiceStore.toggleDeafen |
Same as mute |
| Camera | handleCameraAction (canonical, from utils/voiceActions) |
Active: bg-accent-mint/20 text-accent-mint, Inactive: same |
| Screen share | handleScreenShareAction (canonical, from utils/voiceActions) |
Same as camera |
| Disconnect | DM call or space voice teardown + pop screen | Always bg-accent-rose text-white |
Screen-share button wiring (load-bearing). The button calls handleScreenShareAction, not voiceStore.toggleScreenShare. The store action only flips the boolean — it never calls getDisplayMedia or publishes a track. handleScreenShareAction is the canonical path (also used by VoiceControlBar and the keybind handler) that calls startScreenShare(room) / stopScreenShare(room) and broadcasts voice status. iOS Safari does not support getDisplayMedia and the call rejects there — that's a platform limitation, not a Backspace bug; the same call behaves identically on desktop and Android.
In-call camera switcher (mobile-only). When isCameraOn === true AND enumerateDevices() returns more than one videoinput, a small chevron pill ("Switch camera", aria-haspopup="menu") is overlaid on the top-right corner of the camera control button. Tapping it opens an upward-expanding menu portaled to document.body (so the popup escapes the control bar's glass-bubble clipping), pinned to the chevron's screen rect via getBoundingClientRect() and re-pinned on resize / capturing scroll. The menu lists every videoinput plus an "Auto (system default)" entry; the current selection is highlighted via aria-checked + a text-txt-primary accent. Selecting an entry calls voiceStore.setCameraDeviceId(deviceId) and closes the picker. The useLiveKit syncCamera effect picks up the store change and calls room.switchActiveDevice('videoinput', target) for an in-place hot-swap (no republish) — see docs/systems/voice.md "Hot-swap mid-call". Click-outside dismissal listens to both mousedown AND touchstart (iOS Safari does not synthesize mousedown reliably from a single tap).
The chevron is gated on isCameraOn && cameraDevices.length > 1 so single-camera phones / desktops never see it. On iOS Safari the videoinput list is populated only after the OS-level camera permission has been granted at least once in the current session — that grant happens when the user first turns on the camera via handleCameraAction (which calls getUserMedia), so by the time the chevron is eligible to show, labels and device IDs are available. Before grant, enumerateDevices() returns one entry with empty deviceId and no label; the gate (length > 1) keeps the chevron hidden, so the user sees no broken-state UI.
Voice tile text-selection suppression. VoiceUser and StreamTile outer containers carry data-context-menu attribute. The global @media (max-width: 767px) rule in globals.css applies user-select: none (and inheriting -webkit-touch-callout: none via the * rule) to every [data-context-menu] element, which also inherits to children. Without this, iOS Safari's long-press handler synthesizes the context menu correctly via useGlobalLongPress, but the OS also triggers native text selection during the 500 ms hold, leaving the entire page's text highlighted in the background after the menu opens. The data-context-menu opt-in is the established convention used by Message.tsx, MobileDmsScreen.tsx, MobileFolderSheet.tsx, and MobileSpacesScreen.tsx.
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.
Layout sizing. The grid body is flex-1 between the 48 px header and the floating control bar; on a 390 × 844 viewport the body is roughly 390 × 720, so:
- 1 participant: the lone tile fills the body minus padding (~366 × 206 at 16 : 9). Just the local user's avatar / camera with the standard speaking-ring + name overlay.
- 2 participants:
useGridLayoutpicks the configuration that maximises tile area. On a portrait phone that'scols=1, rows=2— two stacked 16 : 9 tiles ~366 × 200 each, vertically centred. - 3–4 participants: typically
cols=2, rows=2(4) orcols=1, rows=3(3). The same area-maximising algorithm runs on mobile and desktop; nothing is mobile-specific. - Focused mode: the focused tile fills the body minus the 120 px (max 20 vh) bottom strip; the strip horizontally scrolls if other-participant count exceeds the visible width.
No mobile-specific min-tile clamp exists. If the area-maximising solver picks an absurdly small tile for many participants, paginate-or-scroll is intentionally not added — the issue is symmetric with desktop and any future mobile-only paginator should land on desktop too.
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) |
| MessageInput | bottom: calc(env(safe-area-inset-bottom) + 6px) (keyboard closed) / 0px (keyboard open) — see "Floating Composer" below |
The root MobileShell normally uses height: 100dvh (dynamic viewport height) to account for mobile browser chrome. When the iOS soft keyboard is open (detected via useVisualViewportInset().keyboardOpen), the shell switches to height: ${visualViewport.height}px so the visible region of the shell is exactly the area above the keyboard. This is the load-bearing mechanism for the floating composer landing flush against the keyboard top — see "Floating Composer" below.
Floating Composer (Chat MessageInput)
The chat composer (MessageInput.tsx) is a glass-bubble floating overlay on both desktop and mobile — there is no flow-positioned mobile branch. The pattern is shared so feature parity is automatic.
Layout model
┌──────────────────────────────────────────────┐
│ MobileChatScreen / MainContent │ ← `relative flex flex-col`
│ ┌────────────────────────────────────────┐ │
│ │ <MessageList /> │ │ ← fills the chat region
│ │ (scrolls; content's `paddingBottom` │ │
│ │ = `var(--composer-clearance, 80px)` │ │
│ │ so the last message always clears │ │
│ │ the bubble with a 12px breathing gap)│ │
│ └────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────┐ │
│ │ <MessageInput /> │ │ ← `position: absolute`
│ │ glass-bubble, translucent │ │ `left-2 right-2 z-[110]`
│ │ bottom = 0 (kbd open) / safe+6 (kbd │ │ (mobile) / `md:left-3 md:right-3 md:bottom-3` (desktop)
│ │ closed). Writes --composer-clearance │ │
│ │ on its parent via ResizeObserver. │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
The MessageList scroll region fills the entire chat area. The composer is position: absolute and overlays the bottom; messages scroll behind the translucent bubble. Containers must declare position: relative for the composer's absolute positioning to resolve correctly — MainContent does this on its outermost flex column; MobileChatScreen does this on the inner messages-and-composer wrapper.
Last-message clearance — the --composer-clearance CSS variable
The MessageList content's paddingBottom is dynamic, driven by a CSS variable named --composer-clearance written to the chat region's wrapper element by MessageInput via a ResizeObserver:
--composer-clearance = composer.height + composer.bottom-offset + 12 px
Where bottom-offset is the gap between the wrapper's bottom edge and the composer's bottom edge (i.e. the resolved value of the composer's bottom style — 12 px on desktop, env(safe-area-inset-bottom) + 6 ≈ 40 px on iPhone with keyboard closed, 0 on mobile with keyboard open). The +12 px constant is the desired breathing-room gap between the last message's bottom edge and the composer's top edge.
MessageList reads var(--composer-clearance, 80px) as paddingBottom. The 80px fallback covers the brief mount window before the first measurement, plus any future surface that mounts a MessageList without a sibling MessageInput.
Why dynamic? The previous static pb-20 (80 px) was sized for the desktop case (composer ≈ 50 px tall + 12 px bottom = 62 px, leaving 18 px of gap). On iPhone with the keyboard closed, the composer's bottom-offset is env(safe-area-inset-bottom) + 6 ≈ 40 px, so composer-height + bottom-offset ≈ 44 + 40 = 84 px — already exceeding the 80 px pb-20, with negative breathing room. The composer also grows when the user replies to a message (banner adds 36 px) or stages attachments (tile row adds 184 px), so any static value is wrong for some configurations. The ResizeObserver-driven CSS variable is the only correct model.
The variable is scoped to the chat region's wrapper rather than :root so future multi-pane layouts (e.g. side-by-side DM list + chat, voice chat side-panel) don't cross-talk; a wrapper-scoped variable inherits naturally to its MessageList descendant.
useVisualViewportInset() — keyboard-aware geometry
iOS Safari's env(safe-area-inset-bottom) is defined relative to the layout viewport (full screen), not the visual viewport (the visible region above the soft keyboard). When the iOS soft keyboard slides up, the layout viewport stays the same height and safe-area-inset-bottom still reports ~34 px (the home-indicator inset). A composer pinned to bottom: env(safe-area-inset-bottom) + 6 px therefore ends up ~40 px above the layout-bottom, which on iPhone 14 Pro is 300+ px above the keyboard — there is a huge empty gap between the composer and the keyboard top.
The hook subscribes to window.visualViewport.resize / scroll and computes:
keyboardOcclusion = window.innerHeight - (visualViewport.offsetTop + visualViewport.height)
It returns { value, keyboardOpen, height, offsetTop }:
value—'<n>px'when the keyboard is open (the occlusion), or the literal'env(safe-area-inset-bottom)'string when it is not. Provided for legacy / fallback use.keyboardOpen—truewhenkeyboardOcclusion > 1.textInputFocused—truewhile a text-entry element holds focus. Required for iOS PWA standalone where iOS itself shrinks the layout viewport for the keyboard, sovv.height === innerHeightandkeyboardOpenstaysfalseeven though the keyboard IS up. Consumers ORkeyboardOpen || textInputFocusedto detect "keyboard probably open". The state-equality check inside the hook MUST include this field — historically it was missing, so on iOS PWA the hook silently dropped focus changes; the composer'sbottomstyle stayed pinned toenv(safe-area-inset-bottom) + 6pxwhile the keyboard was open, the--composer-clearanceResizeObserver effect's deps fired stale values, and on close the last message overlapped the composer's top edge by ~4 px.height— livevisualViewport.heightin pixels (ornullifvisualViewportis unavailable).offsetTop— livevisualViewport.offsetTopin pixels.
iOS PWA standalone — the load-bearing mechanism
MobileShell consumes { keyboardOpen, height } and sets its own style.height to ${vv.height}px whenever the keyboard is open. The chat region's bottom edge is therefore exactly the keyboard's top edge, and <MessageInput style={{ bottom: 0 }}> lands flush. This is the primary mechanism, not the inset arithmetic — sizing the container is far more robust than arithmetic on a bottom value, because the math depends on vv.resize events firing reliably (which they do not in iOS standalone PWA on several iOS versions). The composer's bottom is a simple binary toggle: 0 when keyboard open, env(safe-area-inset-bottom) + 6 px when closed.
To cover the case where vv.resize fails to fire on iOS PWA (a long-standing standalone-mode bug), the hook also listens to focusin / focusout on window for any text-entry element and polls vv.height at 32 ms intervals for up to 600 ms after the focus change. Polling exits early once the height is stable for two consecutive ticks. This catches the case where iOS silently updates vv.height without dispatching a resize event — the polling just re-reads the value and re-derives keyboardOpen, which then triggers the shell-height update.
When the keyboard is closed, MobileShell reverts to height: 100dvh so the shell again extends through the home-indicator safe area, and the composer reverts to bottom: env(safe-area-inset-bottom) + 6 px so it sits 6 px above the home indicator.
Viewport meta hint
packages/web/index.html's viewport <meta> includes interactive-widget=resizes-content. Chrome (Android) honors this by resizing the layout viewport when the soft keyboard opens, which is the cleaner native equivalent of what MobileShell does manually. Safari iOS does not honor it, but it's harmless there.
MessageInput's mobile vs. desktop class split
The component declares one composerClass shared by both modes. Differences:
- Mobile inline
style={{ bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }}— applied only whenuseUIStore.isMobile === true. The hook is safe to call on desktop (no-ops), but thestyleis only emitted on mobile so desktop's CSS-drivenmd:bottom-3(12 px) constant is unaffected by the inline override. - Tailwind:
absolute left-2 right-2 z-[110] glass-bubble rounded-[14px] md:left-3 md:right-3 md:bottom-3. Theleft-2/right-28 px inset is mobile;md:left-3/right-3/bottom-3overrides to 12 px on desktop.bottomis intentionally NOT in the Tailwind class on mobile — the inlinestyle.bottomprovides the dynamic value.
TypingIndicator is rendered inside MessageInput (anchored absolute bottom-full to the bubble) so it appears just above the composer. Mobile chat screens must NOT render an additional TypingIndicator themselves.
Composer-element ref shape
MessageInput tracks the live composer DOM element via a callback ref that fans out to (a) the existing imperative popoverAnchorRef (consumed by InputPopover and the mention popover for anchor positioning) AND (b) a state-backed composerEl slot that drives the --composer-clearance ResizeObserver effect. The state-backed slot is required because the component renders different JSX when canSendMessages is false (the no-permission early-return path) vs. true (the full composer): a plain useEffect keyed only on stable deps would not re-fire when the ref attaches as the JSX flips, leaving the CSS variable unset until the next dep change. Channel permissions arrive asynchronously, so the initial mount renders the no-permission JSX first and re-renders the full composer once permissions resolve — the callback ref's setComposerEl call re-fires the effect at that moment.
Bottom-Sheet Drag-to-Close (useDragToClose)
File: packages/web/src/hooks/useDragToClose.ts
Three hand-rolled bottom sheets share this gesture hook so each surface gets identical iOS-native-feeling dismissal without depending on a third-party gesture library:
| Sheet | File | Drag-handle area |
|---|---|---|
| Emoji / GIF picker | packages/web/src/components/chat/InputPopover.tsx (MobileSheet) |
Visible pill + tab bar |
| Voice-channel join sheet | packages/web/src/components/voice/MobileVoiceJoinSheet.tsx |
Visible pill + title row |
| Space-folder sheet | packages/web/src/components/layout/MobileFolderSheet.tsx |
Visible pill + folder header row |
Hook contract
const { sheetStyle, handleProps, isDragging, isClosing, hasInteracted } = useDragToClose({
onClose, // required
closeThreshold = 100, // px below resting → commits to close on release
velocityThreshold = 0.5, // px/ms downward → commits to close on release
closeAnimationMs = 200, // close-out transition duration (matches the open keyframe)
enabled = true,
});
sheetStyle— spread onto the sheet container's inlinestyle. While dragging, appliestransform: translateY(<dy>px)withtransition: noneso the sheet follows the finger 1:1. On release,transition: transform <closeAnimationMs>ms cubic-bezier(0.22, 1, 0.36, 1)engages and the inlinetransformglides smoothly. If the close gate is met,dragOffsetis animated from the current offset directly to viewport height (no intermediate snap-back to 0), andonClosefires after the animation completes.handleProps—{ onTouchStart }. Spread onto the drag-handle area (the visible pill + the sheet's header row, never the scrollable content). Scrollable regions and tappable buttons inside the body are unaffected because the document-leveltouchmove/touchendlisteners are only installed once a drag is in flight.isDragging— true betweentouchstartandtouchend.isClosing— true during the close-out animation phase (after the threshold/velocity gate fires, untilonClosefires).hasInteracted— flipstrueon the first touchstart and staystruefor the lifetime of the consumer mount. Consumers MUST gate their open-animation classes on!hasInteracted(e.g.${hasInteracted ? '' : 'animate-slide-up-sheet'}) so the open keyframe doesn't re-run during snap-back / close-out and fight the inlinetransformdriven bysheetStyle. UsingisDraggingalone is insufficient — onceisDraggingflips back to false (release), the keyframe re-applies on the next render and thetranslateY(100%) → translateY(0)ramp visually overrides the glide.
Gesture behaviour
| Phase | Behaviour |
|---|---|
| Touch start on handle | Captures finger Y + performance.now(). No visible change yet. hasInteracted flips to true. |
| Move dy < 6 px | Dead-zone — ignored, native scrolling/pull-to-refresh still possible. |
| Move dy ≥ 6 px | Commits to drag. e.preventDefault() blocks scroll/pull-to-refresh. Sheet follows finger. |
| Release dy < 100 px and v < 0.5 px/ms | Snap back: dragOffset → 0 via inline transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1). Sheet stays open. |
| Release dy ≥ 100 px or v > 0.5 px/ms (and dy > 16 px) | Snap close: isClosing flips on, transition engages, dragOffset ramps from current value to window.innerHeight over closeAnimationMs, then onClose() fires. No intermediate snap to 0. |
| Touch cancel | Snap back to 0 with the same transition (consistent with release-without-commit). |
Coexistence with other patterns
- Tap-outside-to-close stays wired via the existing backdrop
<div>— independent code path, unaffected by the gesture hook. - Tap-on-handle is treated as a no-op (touch starts and ends inside the dead-zone → no offset → no commit).
hasInteracteddoes flip true, but withdragOffset === 0the inline transform stays attranslateY(0)and the visible state matches the open state. - iOS pull-to-refresh is blocked because
touchmoveis non-passive and callspreventDefault()once we cross the dead-zone. - Internal scrolling (e.g. emoji grid, GIF results, folder space list) is untouched —
handleProps.onTouchStartis bound to the header element only, so scroll containers below it never enter drag mode. - Open animation (
animate-slide-up-sheetforMobileFolderSheet/InputPopover.MobileSheet, thetranslate-y-full → translate-y-0flip forMobileVoiceJoinSheet) is gated by!hasInteracted. After the first touch, the open class never re-applies for the rest of the sheet's lifetime — the inlinetransform+ transition becomes the sole animator for both snap-back and close-out.
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 |
| Toast container | z-[300] |
ToastContainer (positioning differs by mobile state — see Toast Positioning) |
| Bottom sheets (backdrop) | z-[300] |
MobileFolderSheet, Add Space sheet, ContextMenu |
| Bottom sheets (content) | z-[301] |
MobileFolderSheet, Add Space sheet, ContextMenu |
Toast Positioning
packages/web/src/components/ui/ToastContainer.tsx is a single shared component. On desktop it renders at bottom-6 right-6 (anchored bottom-right). On mobile the container is repositioned to clear the bottom chrome and center horizontally so toasts don't get cropped against narrow viewports or hidden behind voice/nav controls.
The mobile bottom offset is computed via resolveMobileBottomOffset(hasStack, topScreen, inVoice) and added to env(safe-area-inset-bottom):
| Mobile State | Bottom Offset (above safe-area-inset-bottom) |
Rationale |
|---|---|---|
topScreen === 'voice-full' |
72px + 12px |
Clears MobileVoiceFullScreen control bar (5 round buttons in glass-bubble with mb-2); bottom nav + mini-bar hidden in this mode |
| Stack non-empty + in voice | 64px + 12px |
Clears MobileVoiceMiniBar (sits above the stacked screen since the bottom nav is hidden when stack non-empty) |
| Stack non-empty + no voice | 12px |
Pushed screens have no bottom nav and no mini-bar |
| Root tab + in voice | 56px + 64px + 12px |
Clears MobileBottomNav (56px) + MobileVoiceMiniBar (~64px) stacked above |
| Root tab + no voice | 56px + 12px |
Clears MobileBottomNav only |
On mobile the container also uses left-3 right-3 + items-center instead of right-6 so toasts center horizontally with max-w-[320px]. This avoids horizontal overlap with bottom-bar controls (which span the full mobile width via mx-2) and stays inside the safe-tap zone on narrow screens.
The container subscribes to useUIStore.isMobile, useUIStore.mobileStack, and useVoiceStore.currentVoiceChannelId, so the offset re-computes reactively whenever any of those change — no manual repositioning needed when the user enters/exits voice or pushes/pops a screen while a toast is on screen.
Loading Skeletons (Mobile Inventory)
Mobile uses the same .skeleton / .skeleton-bar / .skeleton-circle / .skeleton-block CSS primitives as desktop (see docs/systems/design-system.md) plus the shared useDelayedLoading hook (200 ms threshold + 300 ms minimum display time). All skeleton placements are content-plane elements rendered on the matte surface — never glass.
| Site | Loading source | Status |
|---|---|---|
App boot (root layout pre-useAuth.user) |
useAuth.isLoading (gated by useDelayedLoading in AppLayout) |
Shared with desktop — AppLayout returns the boot skeleton before the mobile/desktop branch split, so both paths show it during cold start |
MessageList initial + pagination |
chatStore per-channel isLoading / isLoadingMore |
Shared with desktop — MessageList is rendered inside both MainContent (desktop) and MobileChatScreen (mobile) |
MobileSpacesScreen channel list |
useSpaceStore.loadingSpaceId === selectedSpaceId |
Mobile-specific render, mirrors desktop ChannelSidebar. Empty-state mascot is settle-gated on loadedSpaceIds to avoid flashing during the pre-skeleton load window (see "Empty-state mascot — settle gate" above) |
MobileMembersScreen member list |
useSpaceStore.loadingSpaceId === spaceId |
Mobile-specific render, mirrors desktop MemberSidebar |
FriendsPage (mobile) |
socialStore.isLoading && friends.length === 0 && requests.length === 0 |
Shared component; renders LoadingSpinner (no skeleton) — used unchanged on mobile |
ExplorePage (mobile) |
exploreStore.isLoading && spaces.length === 0 |
Shared component; renders LoadingSpinner — used unchanged on mobile |
GifPicker (mobile sheet) |
per-fetch local state | Shared component; uses animate-pulse placeholder tiles — used unchanged on mobile |
Sizing rule: mobile skeleton rows must match the row geometry of the real content for that screen so the populate is smooth and no layout shift occurs. The MobileSpacesScreen channel-row skeleton is px-3 py-2 with a w-4 h-4 icon (~36 px row); the MobileMembersScreen row skeleton is gap-2.5 px-2 py-2.5 with a w-9 h-9 avatar (~52 px row). Both use staggered animationDelay so the shimmer cascades across rows rather than pulsing in lockstep.
Why no shared <Skeleton /> primitive: the project's established pattern composes the existing CSS classes inline at each site (see AppLayout.tsx, ChannelSidebar.tsx, MemberSidebar.tsx, MessageList.tsx). Each site needs row geometry that matches its specific layout, so a parameterized component would either over-abstract (<Row count={N} avatarSize={S} ...>) or duplicate the inline approach. Adding a JSX wrapper over <div className="skeleton"> would also obscure the visual diff between the placeholder and the real row it replaces.
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 MobileSpacesScreen to remember the most-recent text channel for each space (via setLastChannel), and by AppLayout's desktop auto-select effect to land on that channel when the user opens a space without a channelId. It is NOT what the mobile Spaces bottom-nav tap reads — that reads useSpaceStore's currentSpaceId ?? lastSelectedSpaceId (see "Tab Tap Behavior" above). lastSelectedSpaceId itself lives in useSpaceStore and is intentionally NOT persisted — only the in-session sticky-memory semantic matters.