wip(mobile): in-progress mobile + voice polish

Snapshot of in-progress work parked here so group-DM-polish can land
cleanly on main. Touches MainContent + mobile-ui.md which overlap with
group-DM-polish; rebase onto post-merge main and resolve conflicts on
those two files manually.

Files: MainContent, MessageInput, MobileVoiceFullScreen (+test), StreamTile,
VoiceUser, useLiveKit, useVisualViewportInset, AudioManager, voiceStore,
voice utils, mobile-ui.md, voice.md, mobile-parity handoff doc.
This commit is contained in:
Jannis Braun
2026-05-10 21:26:40 +02:00
parent 6fb38d391c
commit dbb9b2c34b
13 changed files with 1241 additions and 232 deletions
+40 -14
View File
@@ -495,27 +495,52 @@ 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-1` for 1-2 participants, `grid-cols-2` for 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
**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:
**Control bar:** `glass-bubble` container with safe area padding.
- Camera tracks (`p.videoTrack` from `ParticipantInfo`) attach to a `<video>` element via LiveKit's `Track.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 `videoTrack` is attached identically, with `muted` on the `<video>` element so the user's own camera doesn't echo through their speakers. (`VoiceUser` sets `muted={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. `setStreamSubscription` and the `stream_watch` data-channel protocol fire identically on mobile.
- Tap-to-focus: tapping any tile sets `voiceStore.focusedParticipantId` and 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 `contextmenu` on long-press), local-mute/volume sliders, watch/unwatch — all carry over.
| 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` |
**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` — flips `true` the first time `focusedParticipantId` changes to anything other than the auto-focused key, or to `null` after 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:** `useGridLayout` picks the configuration that maximises tile area. On a portrait phone that's `cols=1, rows=2` — two stacked 16 : 9 tiles ~366 × 200 each, vertically centred.
- **34 participants:** typically `cols=2, rows=2` (4) or `cols=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
@@ -645,6 +670,7 @@ keyboardOcclusion = window.innerHeight - (visualViewport.offsetTop + visualViewp
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``true` when `keyboardOcclusion > 1`.
- `textInputFocused``true` while a text-entry element holds focus. Required for iOS PWA standalone where iOS itself shrinks the layout viewport for the keyboard, so `vv.height === innerHeight` and `keyboardOpen` stays `false` even though the keyboard IS up. Consumers OR `keyboardOpen || textInputFocused` to 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's `bottom` style stayed pinned to `env(safe-area-inset-bottom) + 6px` while the keyboard was open, the `--composer-clearance` ResizeObserver effect's deps fired stale values, and on close the last message overlapped the composer's top edge by ~4 px.
- `height` — live `visualViewport.height` in pixels (or `null` if `visualViewport` is unavailable).
- `offsetTop` — live `visualViewport.offsetTop` in pixels.
+46 -1
View File
@@ -17,6 +17,26 @@ Source files:
5. Client calls `POST /api/livekit/token { channelId }` → gets JWT + LiveKit URL
6. Client connects to LiveKit room with token
### Microphone pre-arm (iOS user-gesture discipline)
`utils/voice.joinVoiceChannel` fires `AudioContext.resume()` and `AudioManager.setInputDevice(inputDeviceId)` (which ends in `getUserMedia({audio:…})`) **synchronously inside** the click handler, before the `connectFn(channelId)` call. iOS Safari only surfaces the microphone permission prompt when `getUserMedia` is invoked from inside an active user-gesture; the original flow only acquired the mic in `useLiveKit`'s `syncMic` effect, which fires AFTER `room.connect()` resolves (token fetch + WS handshake) — many awaits past the gesture window. iOS PWA standalone is especially strict and would silently never surface the prompt; the user would see "Waiting for others to join…" indefinitely until they locked/unlocked the device (which iOS treats as a fresh activation).
Pre-arm is fire-and-forget: the mic acquisition runs in parallel with the LiveKit handshake, and `AudioManager.inputSwitchChain`'s serialization guarantees `useLiveKit.syncMic`'s subsequent call short-circuits on the already-acquired `currentStream` (no double prompt, no second `getUserMedia`).
### Listener mode (`micPermissionDenied`)
When the user denies the prompt (or has previously denied at the OS level), the pre-arm's `setInputDevice` rejects with `NotAllowedError`. The `voiceStore.micPermissionDenied` flag is set to `true` and the LiveKit connect proceeds anyway — the user appears in the voice channel as a connected participant who can hear others but has no microphone publication. `useLiveKit.syncMic` checks the flag at the top of its body and skips the publish branch entirely.
The flag clears via:
- `requestMicPermission()` (in `utils/voice.ts`) — must be called from a user-gesture handler (button click). Clears `AudioManager.inputDenialError` cache, calls `setInputDevice` from a fresh activation. On success, sets `micPermissionDenied=false` and `useLiveKit.syncMic` re-fires (dep on `micPermissionDenied`) to publish the freshly acquired track.
- `voiceStore.leaveVoice()` / `handleForceDisconnect()` / `resetSession()` / `reset()` — flag resets so the next join attempts a fresh prompt.
UI affordances:
- **Mobile (`MobileVoiceFullScreen`):** banner below the header reads "Microphone access denied — You're listening only". A right-aligned "Allow microphone" button calls `requestMicPermission()`.
- **Desktop (`VoiceControlBar`):** *(Future)* — same listener-mode state needs a parity affordance. Desktop is unaffected by the iOS gesture-window bug in practice (browsers there prompt on `getUserMedia` regardless of activation state), but if a desktop user denies the prompt, the same flow applies.
`AudioManager.inputDenialError` caches the most recent `NotAllowedError`. Subsequent `setInputDevice` calls re-throw the cached error rather than firing a second `getUserMedia` — iOS otherwise would queue a second prompt that has lost its activation, leading to a silent hang. Cleared by `AudioManager.clearInputDenial()` (called from `joinVoiceChannel`'s pre-arm and from `requestMicPermission`).
**Token grants (space channels):**
- SPEAK → can publish MICROPHONE + CAMERA
- STREAM → can publish SCREEN_SHARE + SCREEN_SHARE_AUDIO
@@ -249,9 +269,34 @@ The "Share system audio" toggle in `ScreenSharePicker` adds an audio track to th
---
## Mobile Voice Rendering
Mobile (`MobileVoiceFullScreen`) renders the **same** `VoiceGrid` component as desktop. There is no mobile-specific tile component — the rendering, attach/detach, adaptive-stream subscription, focused-publisher layout, and context menus all come from the shared `VoiceGrid` / `VoiceUser` / `StreamTile` pipeline. The only mobile-specific addition is auto-focus on the first live screen-share publication (so phone users don't have to discover tap-to-focus).
See `docs/systems/mobile-ui.md` → "MobileVoiceFullScreen" for the auto-focus state machine, control-bar wiring, and layout sizing.
**Why the shared component path matters.**
- Local camera preview: `VoiceUser` attaches the local participant's `videoTrack` to a `<video muted>`. Mobile gets the self-preview "for free".
- Remote cameras: `Track.attach(videoEl)` registers the element with LiveKit's `RemoteVideoTrack` adaptive-stream observer, so the SFU automatically picks the appropriate simulcast layer based on the painted tile size on the phone. No mobile-specific bitrate clamp is needed.
- Screen-share: `StreamTile` lazily subscribes via `setStreamSubscription` only after the user taps "Watch Stream" (or auto-focus does so on mobile, which currently still requires the user to tap the in-tile "Watch Stream" CTA — auto-focus only sets the focused publisher; it does not auto-subscribe to bandwidth-heavy screen-share tracks).
- Mute / deafen / speaking-ring overlays, watch/unwatch controls, local mute, volume sliders — identical between mobile and desktop.
**Screen-share button wiring on mobile.** `MobileVoiceFullScreen`'s screen-share button calls `handleScreenShareAction()` from `utils/voiceActions`, **not** `voiceStore.toggleScreenShare`. The store action only flips the `isScreenSharing` boolean and never calls `getDisplayMedia`. The canonical `handleScreenShareAction` is shared with desktop's `VoiceControlBar` and the keybind manager; it calls `startScreenShare(room)` / `stopScreenShare(room)` and broadcasts voice status to peers. iOS Safari does not support `getDisplayMedia` (the call rejects); this is a platform limitation. Android Chrome supports it and works.
---
## Voice Fullscreen
The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` calls `voiceContainerRef.current.requestFullscreen()` (and exits via `document.exitFullscreen()` when the flag clears). A second effect listens to `fullscreenchange` and reflects the actual `document.fullscreenElement` back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active.
The fullscreen toggle in `VoiceControlBar` flips the `voiceFullscreen` flag in `uiStore`; an effect in `MainContent.tsx` enters/exits the browser's Fullscreen API on `voiceContainerRef`. A second effect listens to `fullscreenchange` and reflects the actual document fullscreen element back into the store, so pressing Esc or system-level fullscreen-exit keeps state in sync. `voiceChatOpen && !voiceFullscreen` hides the side chat panel while fullscreen is active.
**Cross-browser API fallback.** iOS Safari (and iPadOS pre-16.4) does not implement the standard `Element.requestFullscreen()` on generic elements, so the enter-fullscreen effect probes for the API in this order:
1. `el.requestFullscreen()` — standard
2. `el.webkitRequestFullscreen()` — older WebKit (some iPads, older Safari)
3. Silent fall-through — pure iPhone Safari has neither API on a `<div>` (only `HTMLVideoElement.webkitEnterFullscreen()` works, which we cannot use for the multi-tile voice container)
When neither native API is available the effect returns without throwing; the `voiceFullscreen` flag still applies `h-screen` to `voiceContainerRef`, which acts as the in-page maximize fallback (chat panel hides, header fades, control bar stays). The exit path mirrors this with `document.exitFullscreen()``document.webkitExitFullscreen()` → no-op. Both paths are wrapped in try/catch so a Promise rejection (e.g. user cancels via Esc mid-transition) does not surface as an unhandled error. The `fullscreenchange` listener is registered for both `fullscreenchange` and `webkitfullscreenchange`. Before this fallback, calling the missing API directly threw `TypeError: requestFullscreen is not a function` on iPhone Safari, which surfaced as a full-screen error overlay when an iPhone user crossed the 768 px desktop breakpoint in landscape mode.
**Overlay portals:** While fullscreen is active the browser's Fullscreen API renders only descendants of `voiceContainerRef`. Every overlay reachable during a call (context menus on `StreamTile`/`VoiceUser`/`VoiceChannel`, tooltips on the control bar, `ConnectionInfoPopover`, `ScreenShareSettingsPopover`, `ConfirmDialog` invoked from voice context-menu actions, and `ScreenSharePicker`) portals through `usePortalContainer()` so it lands inside the fullscreen element. Adding new overlays that can be opened from inside the call must follow the same contract — see `docs/systems/design-system.md` Surface Material Tiers.
+41
View File
@@ -32,6 +32,17 @@ export class AudioManager {
private rnnoiseReady = false;
private keepAliveOscillator: OscillatorNode | null = null;
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
// second `getUserMedia` — iOS Safari otherwise queues a second permission
// prompt that has lost its user-gesture activation, which on iOS PWA
// standalone leads to a permanently hung silent prompt. The cache is
// cleared by `clearInputDenial()` (called from the user-gesture-driven
// `requestMicPermission` retry path in `utils/voice.ts`) so a fresh user
// gesture can re-attempt cleanly.
private inputDenialError: Error | null = null;
// Subscribers notified when the *upstream* getUserMedia track ends unexpectedly
// (hardware unplug, OS-level revoke, system audio service crash). Distinct from
// the published mic track's `onended` — the published track is a clone of the
@@ -209,6 +220,12 @@ export class AudioManager {
return this.currentStream;
}
// Re-throw cached denial without firing a second `getUserMedia`.
// See the `inputDenialError` field comment for rationale.
if (this.inputDenialError) {
throw this.inputDenialError;
}
try {
if (this.currentStream) {
// Detach our `onended` handlers BEFORE stopping. `.stop()` synchronously
@@ -248,6 +265,11 @@ export class AudioManager {
};
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
// Successful acquisition — clear any cached denial so next swap is
// unimpeded. (Most commonly hit when the user grants permission via
// the explicit `requestMicPermission` retry path, but also covers
// OS-level grants between calls.)
this.inputDenialError = null;
this.currentStream = newStream;
this.currentInputDeviceId = deviceId;
this.streamGeneration++;
@@ -269,10 +291,29 @@ export class AudioManager {
return this.currentStream;
} catch (err) {
console.error('[AudioManager] Failed to set input device:', err);
// Cache permission denials so syncMic's racing call doesn't fire a
// second `getUserMedia` while the user is still resolving the first
// prompt (or on iOS PWA where the second prompt would silently
// never surface).
if (err instanceof Error && err.name === 'NotAllowedError') {
this.inputDenialError = err;
}
throw err;
}
}
/**
* Clears the cached `getUserMedia` denial so the next `setInputDevice`
* call attempts a fresh acquisition. Called from
* `utils/voice.requestMicPermission` (always invoked from a user
* gesture) to re-arm the path after a denial. Without this, the cache
* would suppress the retry and the user would be stuck in listener
* mode for the rest of the session.
*/
clearInputDenial(): void {
this.inputDenialError = null;
}
/**
* Attaches an `onended` listener to every audio track in the supplied stream.
* The listener identity-checks against `this.currentStream` so it only fires
@@ -606,9 +606,26 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
// `z-[110]` keeps the bubble above any in-chat overlays (mention popover,
// staged-attachment tiles) but below modals (`z-[300]+`).
const isMobile = useUIStore((s) => s.isMobile);
const { keyboardOpen } = useVisualViewportInset();
const { keyboardOpen, textInputFocused } = useVisualViewportInset();
// iOS PWA standalone shrinks the *layout viewport* itself for the keyboard
// (interactive-widget=resizes-content / native standalone behavior), so
// `vv.height` matches `innerHeight` and the height-delta-inferred
// `keyboardOpen` stays false even though the keyboard IS up. Focus state is
// the robust fallback: a text input being focused means the keyboard is up.
// - keyboardOpen true (Android Chrome): MobileShell already shrunk to
// `vv.height`; composer at `bottom: 0` lands on the keyboard top.
// - keyboardOpen false but textInputFocused true (iOS PWA): layout viewport
// already shrunk by iOS; composer at `bottom: 4px` lands ~4 px above the
// keyboard top — the tight visual gap the user wants.
// - both false: composer 6 px above the home indicator, the rest state.
const composerStyle: React.CSSProperties | undefined = isMobile
? { bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }
? {
bottom: keyboardOpen
? '0px'
: textInputFocused
? '4px'
: 'calc(env(safe-area-inset-bottom) + 6px)',
}
: undefined;
const composerClass =
'absolute left-2 right-2 z-[110] glass-bubble rounded-[14px]' +
@@ -700,7 +717,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
// offset between renders. The ResizeObserver itself is what catches
// continuous textarea-autosize growth; these deps just ensure we're
// attached to the live element after a remount.
}, [composerEl, isMobile, keyboardOpen, chatReplyTo, stagedTransfers.length]);
}, [composerEl, isMobile, keyboardOpen, textInputFocused, chatReplyTo, stagedTransfers.length]);
// Combined ref: keep `popoverAnchorRef` populated (InputPopover / mention
// popover anchor + scroll-into-view targets) AND notify the
@@ -63,24 +63,85 @@ export function MainContent() {
setSearchOpen(false);
}, [currentChannelId]);
// Handle actual browser fullscreen API
useEffect(() => {
const handleFullscreenChange = () => {
setVoiceFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [setVoiceFullscreen]);
// Handle actual browser fullscreen API.
//
// iOS Safari (and iPadOS pre-16.4) does NOT implement the standard
// `Element.requestFullscreen()` on generic elements. Older WebKit exposes a
// `webkitRequestFullscreen` variant; pure iPhone has neither for a `<div>`
// (only `HTMLVideoElement.webkitEnterFullscreen()` works, which we cannot use
// for the multi-tile voice container). On those platforms we still want the
// toggle to work as an in-page maximize: the `voiceFullscreen` flag flips the
// container to `h-screen` so the call view fills the viewport — that's the
// graceful fallback. Calling a missing API directly threw
// `TypeError: requestFullscreen is not a function` on iPhone, taking down
// the whole voice UI; the guards below contain the platform difference.
type FullscreenCapableElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void;
};
type FullscreenCapableDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => Promise<void> | void;
};
const getFullscreenElement = useCallback((): Element | null => {
const d = document as FullscreenCapableDocument;
return document.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}, []);
useEffect(() => {
if (voiceFullscreen && voiceContainerRef.current && !document.fullscreenElement) {
voiceContainerRef.current.requestFullscreen().catch(err => {
console.error('Error attempting to enable full-screen mode:', err);
});
} else if (!voiceFullscreen && document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
const handleFullscreenChange = () => {
setVoiceFullscreen(!!getFullscreenElement());
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, [setVoiceFullscreen, getFullscreenElement]);
useEffect(() => {
const el = voiceContainerRef.current as FullscreenCapableElement | null;
const d = document as FullscreenCapableDocument;
const inFullscreen = !!getFullscreenElement();
const enter = async () => {
if (!el) return;
try {
if (typeof el.requestFullscreen === 'function') {
await el.requestFullscreen();
} else if (typeof el.webkitRequestFullscreen === 'function') {
await Promise.resolve(el.webkitRequestFullscreen());
}
// If neither API exists (iPhone Safari / PWA standalone for non-video
// elements), fall through silently — the `voiceFullscreen` flag still
// applies `h-screen` to give an in-page maximize, which is the
// documented graceful fallback.
} catch (err) {
// Permission denied, user gesture missing, or platform refusal. Keep
// the in-page fallback (h-screen) without surfacing a console error.
console.warn('Native fullscreen unavailable, using in-page fallback:', err);
}
};
const exit = async () => {
try {
if (typeof document.exitFullscreen === 'function') {
await document.exitFullscreen();
} else if (typeof d.webkitExitFullscreen === 'function') {
await Promise.resolve(d.webkitExitFullscreen());
}
} catch {
// best-effort
}
};
if (voiceFullscreen && el && !inFullscreen) {
void enter();
} else if (!voiceFullscreen && inFullscreen) {
void exit();
}
}, [voiceFullscreen]);
}, [voiceFullscreen, getFullscreenElement]);
// 2. LOGIC AND EARLY RETURNS
const channel = channels.find(c => c.id === currentChannelId);
@@ -0,0 +1,295 @@
/**
* Verifies the mobile voice fullscreen renders the same VoiceGrid pipeline
* as desktop, so cameras (local + remote) and screen-share tiles appear in
* the participant grid.
*
* jsdom can't host a real LiveKit Room, so we test at the rendering layer:
* - Seed `voiceStore.participants` with synthetic ParticipantInfo entries.
* - Mount MobileVoiceFullScreen.
* - Assert the DOM contains the right number of <video> elements (one per
* user-tile-with-camera + one per stream-tile).
*
* If anyone re-introduces the avatar-only mobile grid, this test fails.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
// jsdom doesn't ship ResizeObserver; useGridLayout needs it.
if (typeof globalThis.ResizeObserver === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
// LiveKit client imports drag in heavy stuff; track.attach on a dead track
// throws in jsdom, so stub the parts that touch DOM.
vi.mock('../../hooks/useWebSocket', () => ({
wsSend: vi.fn(),
useWebSocket: vi.fn(),
}));
vi.mock('../../hooks/useLiveKit', async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
getActiveRoom: () => null,
setStreamSubscription: vi.fn(),
setCameraSubscription: vi.fn(),
};
});
// AudioManager is a singleton with browser-only deps.
vi.mock('../../audio/AudioManager', () => ({
AudioManager: { getInstance: () => ({ resumeContext: vi.fn() }) },
}));
vi.mock('../../utils/voiceActions', async () => ({
handleCameraAction: vi.fn(),
handleScreenShareAction: vi.fn(),
}));
function makeFakeMediaStreamTrack(kind: 'video' | 'audio'): MediaStreamTrack {
// Minimal stand-in. VoiceGrid's deriveGridTiles only inspects
// p.videoTrack?.readyState; VoiceUser/StreamTile attach via
// lkVideoTrack/lkScreenTrack which we mock as null below.
const t = {
kind,
readyState: 'live',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getSettings: () => ({ height: 720, frameRate: 30 }),
stop: vi.fn(),
} as unknown as MediaStreamTrack;
return t;
}
function makeParticipant(overrides: Partial<any>): any {
return {
identity: '1:alice',
userId: '1',
username: 'alice',
homeUserId: null,
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
isLocal: false,
audioTrack: null,
videoTrack: null,
screenTrack: null,
screenAudioTrack: null,
lkVideoTrack: null,
lkScreenTrack: null,
cachedUser: null,
...overrides,
};
}
beforeEach(() => {
// Reset relevant store slices.
useVoiceStore.setState({
currentVoiceChannelId: 'channel-A',
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
voiceUsers: new Map(),
voiceUserStates: new Map(),
spaceMutedUserIds: new Set(),
spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
speakingParticipantIds: new Set(),
focusedParticipantId: null,
activeDmCall: null,
participantMutes: new Map(),
streamMutes: new Map(),
watchingStreams: new Set(),
unwatchedCameras: new Set(),
});
useSpaceStore.setState({
channels: [{ id: 'channel-A', name: 'general', type: 'voice' } as any],
dmChannels: [],
spaces: [],
channelToSpaceMap: new Map([['channel-A', 'space-A']]),
members: [],
} as any);
useAuthStore.setState({
user: { id: '1', username: 'alice', displayName: 'Alice' },
} as any);
});
function renderScreen() {
return render(
<MemoryRouter>
<MobileVoiceFullScreen />
</MemoryRouter>,
);
}
describe('MobileVoiceFullScreen', () => {
it('renders the VoiceGrid waiting state when no participants', () => {
const { container } = renderScreen();
// No <video> until participants exist.
expect(container.querySelectorAll('video').length).toBe(0);
// Waiting copy or "0 connected".
expect(container.textContent).toMatch(/0 connected/);
});
it('renders a video element for the local user when their camera is on', () => {
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '1:alice',
userId: '1',
username: 'alice',
isLocal: true,
isCameraOn: true,
videoTrack: makeFakeMediaStreamTrack('video'),
}),
],
});
const { container } = renderScreen();
// One user-tile, one <video> for the local camera.
const videos = container.querySelectorAll('video');
expect(videos.length).toBe(1);
// Local video must be muted to prevent echo.
expect(videos[0]).toHaveAttribute('autoplay');
expect((videos[0] as HTMLVideoElement).muted).toBe(true);
});
it('renders a video element for a remote user with camera on', () => {
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '1:alice',
userId: '1',
username: 'alice',
isLocal: true,
}),
makeParticipant({
identity: '2:bob',
userId: '2',
username: 'bob',
isLocal: false,
isCameraOn: true,
videoTrack: makeFakeMediaStreamTrack('video'),
}),
],
});
const { container } = renderScreen();
// Alice (no camera) → avatar tile with no <video>.
// Bob (camera on) → <video> tile.
expect(container.querySelectorAll('video').length).toBe(1);
expect(container.textContent).toMatch(/2 connected/);
});
it('renders an extra StreamTile when a participant publishes screen-share', () => {
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '1:alice',
userId: '1',
username: 'alice',
isLocal: true,
}),
makeParticipant({
identity: '2:bob',
userId: '2',
username: 'bob',
isLocal: false,
isScreenSharing: true,
screenTrack: makeFakeMediaStreamTrack('video'),
}),
],
});
const { container } = renderScreen();
// The grid should now contain Bob's user tile + a separate StreamTile
// (avatar placeholder until Watch Stream is tapped). Look for the LIVE
// badge in the StreamTile.
expect(container.textContent).toMatch(/LIVE/);
expect(container.textContent).toMatch(/is streaming/i);
});
it('auto-focuses the first live screen-share publication on mount', async () => {
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '1:alice',
userId: '1',
username: 'alice',
isLocal: true,
}),
makeParticipant({
identity: '2:bob',
userId: '2',
username: 'bob',
isLocal: false,
isScreenSharing: true,
screenTrack: makeFakeMediaStreamTrack('video'),
}),
],
});
renderScreen();
// Auto-focus runs in a useEffect. Flush.
await new Promise((r) => setTimeout(r, 0));
expect(useVoiceStore.getState().focusedParticipantId).toBe('2:bob:stream');
});
it('clears focus on unmount so re-entering the screen is a clean slate', async () => {
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '2:bob',
userId: '2',
username: 'bob',
isLocal: false,
isScreenSharing: true,
screenTrack: makeFakeMediaStreamTrack('video'),
}),
],
});
const { unmount } = renderScreen();
await new Promise((r) => setTimeout(r, 0));
expect(useVoiceStore.getState().focusedParticipantId).toBe('2:bob:stream');
unmount();
expect(useVoiceStore.getState().focusedParticipantId).toBeNull();
});
it('renders a screen-share button that calls the canonical handler', async () => {
const { handleScreenShareAction } = await import('../../utils/voiceActions');
useVoiceStore.setState({
participants: [
makeParticipant({
identity: '1:alice',
userId: '1',
username: 'alice',
isLocal: true,
}),
],
});
const { container } = renderScreen();
const btn = container.querySelector(
'button[aria-label="Share screen"], button[aria-label="Stop sharing screen"]',
) as HTMLButtonElement | null;
expect(btn).toBeTruthy();
btn?.click();
expect(handleScreenShareAction).toHaveBeenCalled();
});
});
@@ -1,15 +1,33 @@
import React from 'react';
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
import { wsSend } from '../../hooks/useWebSocket';
import { getChannelOrigin } from '../../stores/spaceStore';
import { handleCameraAction } from '../../utils/voiceActions';
import {
handleCameraAction,
handleScreenShareAction,
} from '../../utils/voiceActions';
import { VoiceGrid } from '../voice/VoiceGrid';
import { deriveGridTiles } from '../../hooks/useLiveKit';
import { requestMicPermission } from '../../utils/voice';
/**
* Full-screen mobile voice/video call view.
*
* Renders the same `VoiceGrid` as desktop so cameras and screen-share tracks
* subscribe + attach via the canonical `Track.attach()` pipeline. The grid
* supports tap-to-focus (single tile takes the bulk of the viewport, others
* collapse into a bottom strip) — feature parity with desktop's focused mode.
*
* Auto-focus on screen-share: when the user is not already focused on a
* specific tile, the first available screen-share tile is auto-focused so
* mobile users don't need to discover the tap-to-focus affordance to watch a
* stream. Auto-focus is mobile-only behaviour; desktop preserves its
* "render-everything-then-let-the-user-pick" pattern.
*/
export function MobileVoiceFullScreen() {
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
@@ -21,24 +39,209 @@ export function MobileVoiceFullScreen() {
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const leaveVoice = useVoiceStore((s) => s.leaveVoice);
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const participants = useVoiceStore((s) => s.participants);
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const channels = useSpaceStore((s) => s.channels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const spaces = useSpaceStore((s) => s.spaces);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const members = useSpaceStore((s) => s.members);
const authUser = useAuthStore((s) => s.user);
const openContextMenu = useContextMenuStore((s) => s.open);
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
const micPermissionDenied = useVoiceStore((s) => s.micPermissionDenied);
// ── In-call camera switcher ───────────────────────────────────────────────
// On mobile, users typically have a front+back camera and need to flip
// mid-call. Desktop exposes per-device selection in user settings, but
// navigating away from the call screen mid-call breaks the flow.
//
// Mechanism: `setCameraDeviceId(deviceId)` writes to voiceStore; the
// canonical `useLiveKit syncCamera` effect picks that up and calls
// `room.switchActiveDevice('videoinput', target)` for an in-place hot-swap
// (no republish). This is the same flow desktop's settings panel uses —
// see docs/systems/voice.md "Hot-swap mid-call".
const [cameraDevices, setCameraDevices] = useState<MediaDeviceInfo[]>([]);
const [cameraPickerOpen, setCameraPickerOpen] = useState(false);
const cameraPickerAnchorRef = useRef<HTMLDivElement>(null);
const cameraPickerPopupRef = useRef<HTMLDivElement>(null);
const [cameraPickerRect, setCameraPickerRect] = useState<{ left: number; bottom: number } | null>(null);
// Enumerate available video inputs. Pure passive — `enumerateDevices`
// does NOT light the camera LED. Labels are populated only after an
// active camera grant; before that they fall back to "Camera N".
// We re-enumerate on every `devicechange` (e.g. AirPods connect, USB
// camera plugged in).
useEffect(() => {
let cancelled = false;
const enumerate = async () => {
try {
const all = await navigator.mediaDevices.enumerateDevices();
if (cancelled) return;
const seen = new Set<string>();
const cams: MediaDeviceInfo[] = [];
for (const d of all) {
if (d.kind !== 'videoinput') continue;
if (seen.has(d.deviceId)) continue;
seen.add(d.deviceId);
cams.push(d);
}
setCameraDevices(cams);
} catch {
if (!cancelled) setCameraDevices([]);
}
};
enumerate();
const onChange = () => enumerate();
navigator.mediaDevices.addEventListener('devicechange', onChange);
return () => {
cancelled = true;
navigator.mediaDevices.removeEventListener('devicechange', onChange);
};
}, []);
// Click-outside to close the picker. Listens to mousedown AND touchstart
// because iOS Safari does not synthesize mousedown reliably from a single
// tap (same pattern as MobileVoiceJoinSheet's picker).
useEffect(() => {
if (!cameraPickerOpen) return;
const onPointerDown = (e: MouseEvent | TouchEvent) => {
const target = e.target as Node;
const inAnchor = cameraPickerAnchorRef.current?.contains(target) ?? false;
const inPopup = cameraPickerPopupRef.current?.contains(target) ?? false;
if (!inAnchor && !inPopup) setCameraPickerOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown, { passive: true });
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [cameraPickerOpen]);
// Pin the popup to the anchor's screen rect on open, reposition on
// resize/scroll. Using `bottom` makes the popup expand upward from the
// chevron — natural for a control bar at the screen bottom.
useEffect(() => {
if (!cameraPickerOpen) {
setCameraPickerRect(null);
return;
}
const anchorBtn = cameraPickerAnchorRef.current?.querySelector('button');
if (!anchorBtn) return;
const update = () => {
const r = anchorBtn.getBoundingClientRect();
setCameraPickerRect({
left: r.left,
bottom: window.innerHeight - r.top + 6,
});
};
update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [cameraPickerOpen]);
// Close the picker if the camera turns off (the trigger is hidden anyway,
// but stale popup state would briefly flash if the camera toggles off
// between renders).
useEffect(() => {
if (!isCameraOn && cameraPickerOpen) setCameraPickerOpen(false);
}, [isCameraOn, cameraPickerOpen]);
const handlePickCamera = useCallback(
(deviceId: string | null) => {
setCameraDeviceId(deviceId);
setCameraPickerOpen(false);
},
[setCameraDeviceId],
);
const showCameraSwitcher = isCameraOn && cameraDevices.length > 1;
// Track whether the user has explicitly chosen focus (manual tap) vs.
// auto-focus we set on screen-share publish. Once the user manually
// focuses or unfocuses anything during this screen's lifetime, we stop
// auto-focusing.
const userTouchedFocusRef = useRef(false);
const lastAutoFocusedKeyRef = useRef<string | null>(null);
// Pre-compute tiles for screen-share auto-focus detection. Cheap (same
// derivation VoiceGrid runs).
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Auto-focus screen-share on mobile.
// - Trigger only when the user has not manually changed focus during this
// screen lifetime AND the current focus is either null or a stale tile
// that no longer exists.
// - Picks the first live screen-share tile.
useEffect(() => {
if (userTouchedFocusRef.current) return;
const liveStreamTiles = tiles.filter(
(t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live',
);
if (liveStreamTiles.length === 0) return;
const firstStreamKey = liveStreamTiles[0]?.key;
if (!firstStreamKey) return;
// Already focused on this stream — nothing to do.
if (focusedParticipantId === firstStreamKey) {
lastAutoFocusedKeyRef.current = firstStreamKey;
return;
}
// Don't override an existing manual focus on a still-valid tile. The
// VoiceGrid effect already clears focus when the focused tile vanishes,
// so reaching here with a non-null focusedParticipantId means the user
// is intentionally focused on something else (cleared by us only if it
// matches the previous auto-focus).
if (
focusedParticipantId &&
focusedParticipantId !== lastAutoFocusedKeyRef.current
) {
return;
}
setFocusedParticipant(firstStreamKey);
lastAutoFocusedKeyRef.current = firstStreamKey;
}, [tiles, focusedParticipantId, setFocusedParticipant]);
// Wrap setFocusedParticipant so we can flag user-initiated focus changes.
// Replace the store action *transparently* via a subscription is fragile;
// instead we wrap by intercepting through a custom click layer below. The
// VoiceGrid uses the store's setFocusedParticipant directly for clicks —
// we can't override that without forking VoiceGrid. Workaround: mark the
// flag whenever focusedParticipantId changes to a value other than what
// we auto-focused.
useEffect(() => {
if (focusedParticipantId === null) {
// User dismissed focus (or VoiceGrid cleared it on stale). Either way,
// the user has now interacted; do not re-auto-focus the same stream.
if (lastAutoFocusedKeyRef.current !== null) {
userTouchedFocusRef.current = true;
}
} else if (focusedParticipantId !== lastAutoFocusedKeyRef.current) {
// User picked a different tile — manual interaction.
userTouchedFocusRef.current = true;
}
}, [focusedParticipantId]);
// Reset focus state when leaving the screen / call.
useEffect(() => {
return () => {
// On unmount, clear focus so re-entering is a clean slate.
useVoiceStore.getState().setFocusedParticipant(null);
};
}, []);
if (!currentVoiceChannelId) {
popMobileScreen();
@@ -51,57 +254,34 @@ export function MobileVoiceFullScreen() {
if (isDmCall) {
const dmId = currentVoiceChannelId.replace('dm-', '');
const dm = dmChannels.find(d => d.id === dmId);
const dm = dmChannels.find((d) => d.id === dmId);
if (dm) {
const others = dm.members.filter(m => m.id !== authUser?.id);
channelName = others.map(m => m.displayName ?? m.username).join(', ');
const others = dm.members.filter((m) => m.id !== authUser?.id);
channelName = others.map((m) => m.displayName ?? m.username).join(', ');
}
} else {
const ch = channels.find(c => c.id === currentVoiceChannelId);
const ch = channels.find((c) => c.id === currentVoiceChannelId);
if (ch) {
channelName = ch.name;
const spaceId = channelToSpaceMap.get(ch.id);
const space = spaceId ? spaces.find(s => s.id === spaceId) : null;
const space = spaceId ? spaces.find((s) => s.id === spaceId) : null;
if (space) spaceName = space.name;
}
}
const participantIds = voiceUsers.get(currentVoiceChannelId) || [];
// Resolve participant display info from members list or DM members
const getParticipantInfo = (userId: string) => {
const member = members.find(m => m.userId === userId);
if (member) {
return {
name: member.nickname ?? member.user?.displayName ?? member.user?.username ?? userId,
avatar: member.user?.avatar ? `/api/uploads/${member.user.avatar}` : null,
avatarColor: member.user?.avatarColor ?? null,
};
}
// Check DM members
for (const dm of dmChannels) {
const dmMember = dm.members.find(m => m.id === userId);
if (dmMember) {
return {
name: dmMember.displayName ?? dmMember.username,
avatar: dmMember.avatar ? `/api/uploads/${dmMember.avatar}` : null,
avatarColor: dmMember.avatarColor,
};
}
}
// Fall back to LiveKit participant metadata
const participant = participants.find(p => p.userId === userId);
if (participant?.username) {
return { name: participant.username, avatar: null, avatarColor: null };
}
return { name: userId, avatar: null, avatarColor: null };
};
const handleDisconnect = () => {
const { activeDmCall, disconnectFn, federatedCallId, callOrigin } = useVoiceStore.getState();
const { activeDmCall, disconnectFn, federatedCallId, callOrigin } =
useVoiceStore.getState();
if (activeDmCall) {
const origin = callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId }, origin);
wsSend(
{
type: 'dm_call_end',
dmChannelId: activeDmCall.dmChannelId,
federatedCallId,
},
origin,
);
useVoiceStore.getState().setActiveDmCall(null);
} else if (currentVoiceChannelId) {
wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId));
@@ -111,160 +291,147 @@ export function MobileVoiceFullScreen() {
popMobileScreen();
};
const handleParticipantContextMenu = (e: React.MouseEvent, userId: string) => {
if (userId === authUser?.id || !currentVoiceChannelId || isDmCall) return;
e.preventDefault();
e.stopPropagation();
const modItems = buildVoiceModMenuItems(userId, currentVoiceChannelId);
const items: ContextMenuItem[] = [...modItems];
if (modItems.length > 0) {
items.push({ key: 'mod-end-sep', type: 'separator' });
}
// Local mute checkbox
items.push({
key: 'mute-user',
type: 'checkbox',
label: 'Mute User',
subscribe: useVoiceStore.subscribe,
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
});
items.push({ key: 'vol-sep', type: 'separator' });
// Volume slider
items.push({
key: 'volume',
type: 'custom',
render: () => <VolumeSliderItem userId={userId} />,
});
if (items.length === 0) return;
openContextMenu({ x: e.clientX, y: e.clientY }, items);
};
return (
<div className="flex flex-col h-full bg-surface-base">
{/* Header */}
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
<button onClick={popMobileScreen} className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
<button
onClick={popMobileScreen}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
aria-label="Collapse call"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-semibold text-txt-primary truncate">{channelName}</h1>
{spaceName && <p className="text-[11px] text-txt-tertiary truncate">{spaceName}</p>}
<h1 className="text-sm font-semibold text-txt-primary truncate">
{channelName}
</h1>
{spaceName && (
<p className="text-[11px] text-txt-tertiary truncate">{spaceName}</p>
)}
</div>
<span className="text-xs text-txt-tertiary">{participantIds.length} connected</span>
<span className="text-xs text-txt-tertiary">
{participants.length} connected
</span>
{!isDmCall && (
<button
onClick={() => pushMobileScreen('members')}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
aria-label="View members"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
</button>
)}
</header>
{/* Participant grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className={`grid gap-3 ${
participantIds.length <= 2 ? 'grid-cols-1' : 'grid-cols-2'
}`}>
{participantIds.map(userId => {
const info = getParticipantInfo(userId);
const wsStatus = voiceUserStates.get(userId);
const participant = participants.find(p => p.userId === userId);
const isMe = userId === authUser?.id;
// Resolve mute/deafen: local state for self, LiveKit participant then WS fallback for others
const isUserMuted = isMe
? isMuted
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const isUserDeafened = isMe
? isDeafened
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
// Server-enforced states
const spaceId = !isDmCall && currentVoiceChannelId
? channelToSpaceMap.get(currentVoiceChannelId)
: undefined;
const isSpaceMuted = spaceId ? spaceMutedUserIds.has(`${spaceId}:${userId}`) : false;
const isSpaceDeafened = spaceId ? spaceDeafenedUserIds.has(`${spaceId}:${userId}`) : false;
const isPermissionMuted = spaceId ? permissionMutedUserIds.has(`${spaceId}:${userId}`) : false;
// Any muted indicator: self-mute, server mute, or permission mute
const showMuted = isUserMuted || isSpaceMuted || isPermissionMuted;
// Any deafened indicator: self-deafen or server deafen
const showDeafened = isUserDeafened || isSpaceDeafened;
return (
<div
key={userId}
data-context-menu
className={`rounded-xl bg-surface-channel p-4 flex flex-col items-center gap-3 ${
participantIds.length <= 2 ? 'py-8' : 'py-4'
}`}
onContextMenu={(e) => handleParticipantContextMenu(e, userId)}
>
<div className="relative">
<Avatar
src={info.avatar}
name={info.name}
avatarColor={info.avatarColor}
size={participantIds.length <= 2 ? 80 : 56}
/>
{(showMuted || showDeafened) && (
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-accent-rose/90 flex items-center justify-center">
{showDeafened ? (
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
</svg>
)}
</div>
)}
</div>
<span className="text-sm text-txt-primary font-medium truncate max-w-full">
{info.name}{isMe ? ' (You)' : ''}
</span>
</div>
);
})}
</div>
{participantIds.length === 0 && (
<div className="flex items-center justify-center h-40 text-txt-tertiary text-sm">
No one else is here yet
{/* Mic-permission denial banner. Surfaces only when the user joined
voice without granting microphone access (most common on iOS PWA
where the permission prompt missed its user-gesture window, and
the user denied or dismissed). The retry button is the second
user-gesture entry-point — it calls `getUserMedia` synchronously
inside the click handler so iOS surfaces the prompt cleanly. On
success the flag clears and `useLiveKit syncMic` re-fires to
publish the freshly acquired mic track. */}
{micPermissionDenied && (
<div className="mx-2 mt-2 px-3 py-2.5 rounded-lg bg-accent-amber/10 border border-accent-amber/30 flex items-center gap-3 shrink-0">
<svg
className="w-5 h-5 text-accent-amber shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.75}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M3 3l18 18"
/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-medium text-accent-amber">
Microphone access denied
</p>
<p className="text-[11px] text-txt-tertiary leading-tight mt-0.5">
You're listening only — others can't hear you.
</p>
</div>
)}
<button
type="button"
onClick={() => {
void requestMicPermission();
}}
className="text-[11px] font-medium px-3 py-1.5 rounded-md bg-accent-amber/20 text-accent-amber hover:bg-accent-amber/30 transition-colors shrink-0"
>
Allow microphone
</button>
</div>
)}
{/* Participant grid — VoiceGrid handles attach/detach, tap-to-focus,
screen-share tiles, mute overlays, context menus. Identical
rendering pipeline to desktop. */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VoiceGrid participants={participants} />
</div>
{/* Control bar */}
<div className="glass-bubble mx-2 mb-2 rounded-2xl flex items-center justify-center gap-4 px-4 py-3 shrink-0"
<div
className="glass-bubble mx-2 mb-2 rounded-2xl flex items-center justify-center gap-4 px-4 py-3 shrink-0"
style={{ marginBottom: 'calc(0.5rem + env(safe-area-inset-bottom))' }}
>
{/* Mute */}
<button
onClick={toggleMute}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isMuted ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
isMuted
? 'bg-accent-rose/20 text-accent-rose'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
{isMuted && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"
/>
{isMuted && (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 3l18 18"
/>
)}
</svg>
</button>
@@ -272,36 +439,124 @@ export function MobileVoiceFullScreen() {
<button
onClick={toggleDeafen}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isDeafened ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
isDeafened
? 'bg-accent-rose/20 text-accent-rose'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isDeafened ? 'Undeafen' : 'Deafen'}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
{isDeafened && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
/>
{isDeafened && (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 3l18 18"
/>
)}
</svg>
</button>
{/* Camera */}
<button
onClick={handleCameraAction}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isCameraOn ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9.75a2.25 2.25 0 002.25-2.25V7.5a2.25 2.25 0 00-2.25-2.25H4.5A2.25 2.25 0 002.25 7.5v9A2.25 2.25 0 004.5 18.75z" />
</svg>
</button>
{/* Camera (with in-call switcher chevron when multiple cameras exist
and the camera is currently on). The chevron sits in a small
attached pill above the bottom-right corner of the camera button —
visible only when relevant so single-camera devices are unaffected. */}
<div className="relative" ref={cameraPickerAnchorRef}>
<button
onClick={handleCameraAction}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isCameraOn
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isCameraOn ? 'Turn camera off' : 'Turn camera on'}
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9.75a2.25 2.25 0 002.25-2.25V7.5a2.25 2.25 0 00-2.25-2.25H4.5A2.25 2.25 0 002.25 7.5v9A2.25 2.25 0 004.5 18.75z"
/>
</svg>
</button>
{showCameraSwitcher && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setCameraPickerOpen((v) => !v);
}}
aria-label="Switch camera"
aria-haspopup="menu"
aria-expanded={cameraPickerOpen}
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated text-txt-primary flex items-center justify-center shadow-md border border-border-soft active:scale-95 transition-transform"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
{/* Camera-flip icon: arrows around a camera */}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 7h3l1.5-2h7L17 7h3v12H4V7z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 13a3 3 0 003 3m3-3a3 3 0 00-3-3M9 13l-1.5-1.5M9 13l1.5-1.5M15 13l1.5 1.5M15 13l-1.5 1.5"
/>
</svg>
</button>
)}
</div>
{/* Screen share */}
{/* Screen share — uses canonical handleScreenShareAction so the
getDisplayMedia call actually fires (and propagates errors via
voiceActions). The previous voiceStore.toggleScreenShare flipped
only the boolean and never started capture. */}
<button
onClick={toggleScreenShare}
onClick={handleScreenShareAction}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isScreenSharing ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
isScreenSharing
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={
isScreenSharing ? 'Stop sharing screen' : 'Share screen'
}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a9 9 0 01-9 9m0 0a9 9 0 01-9-9" />
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a9 9 0 01-9 9m0 0a9 9 0 01-9-9"
/>
</svg>
</button>
@@ -309,12 +564,75 @@ export function MobileVoiceFullScreen() {
<button
onClick={handleDisconnect}
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
aria-label="Disconnect from call"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
</button>
</div>
{/* Camera picker popup — portaled to document.body so the upward
expansion isn't clipped by the control bar's glass-bubble.
Anchored to the chevron via getBoundingClientRect (recomputed on
resize / scroll). max-height + overflow-y-auto + iOS scroll
momentum keep every entry reachable on devices with many cameras. */}
{showCameraSwitcher &&
cameraPickerOpen &&
cameraPickerRect &&
createPortal(
<div
ref={cameraPickerPopupRef}
role="menu"
aria-label="Select camera"
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
style={{
left: cameraPickerRect.left,
bottom: cameraPickerRect.bottom,
minWidth: 180,
maxWidth: 260,
maxHeight: 'min(50vh, 320px)',
WebkitOverflowScrolling: 'touch',
}}
>
<button
type="button"
role="menuitemradio"
aria-checked={cameraDeviceId === null}
onClick={() => handlePickCamera(null)}
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
cameraDeviceId === null ? 'text-txt-primary' : 'text-txt-secondary'
} active:bg-interactive-hover`}
>
Auto (system default)
</button>
{cameraDevices.map((d, i) => (
<button
key={d.deviceId}
type="button"
role="menuitemradio"
aria-checked={cameraDeviceId === d.deviceId}
onClick={() => handlePickCamera(d.deviceId)}
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
cameraDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
} active:bg-interactive-hover`}
>
{d.label || `Camera ${i + 1}`}
</button>
))}
</div>,
document.body,
)}
</div>
);
}
@@ -340,6 +340,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
return (
<div
data-context-menu
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${
'h-full w-full'
}`}
@@ -141,6 +141,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
return (
<div
data-context-menu
className={`relative bg-surface-base rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
isSpeaking
? 'ring-[3px] ring-status-online shadow-[0_0_12px_rgba(134,239,172,0.25)]'
+33 -2
View File
@@ -210,6 +210,7 @@ export function useLiveKit() {
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
const micPermissionDenied = useVoiceStore((s) => s.micPermissionDenied);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
@@ -388,6 +389,19 @@ export function useLiveKit() {
const micPub = r.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.Microphone);
// Mic permission denied — user joined as a listener. Tear down any
// stale publication (defensive: should not exist on first join, but
// covers the edge where permission was revoked mid-call) and skip
// the publish branch entirely. Re-attempt is gated behind the
// `requestMicPermission` user-gesture path which clears the flag
// and triggers this effect to re-run via its dep.
if (micPermissionDenied) {
if (micPub?.track) {
await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack).catch(() => {});
}
return;
}
// If effectively muted or deafened, mute the track in-place (keep it published)
if (effectiveMuted || effectiveDeafened) {
if (micPub?.track && !micPub.isMuted) {
@@ -399,7 +413,24 @@ export function useLiveKit() {
// Not muted — ensure the AudioManager pipeline is on the right device
// and at the right volume, then republish if the published track is
// stale or missing.
await audioManager.setInputDevice(inputDeviceId);
try {
await audioManager.setInputDevice(inputDeviceId);
} catch (err: any) {
// Late denial (e.g. iOS standalone PWA where the pre-arm in
// `joinVoiceChannel` ran inside the gesture but the prompt is
// delivered out-of-band; the user denies after `room.connect()`
// has already succeeded). Promote to the listener-mode flag so
// we don't loop forever trying to re-acquire.
if (err?.name === 'NotAllowedError') {
useVoiceStore.getState().setMicPermissionDenied(true);
useUIStore.getState().addToast(
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
'warning',
);
return;
}
throw err;
}
audioManager.setInputVolume(inputVolume);
await republishMicrophone(r, lastMicGenRef);
} catch (err) {
@@ -417,7 +448,7 @@ export function useLiveKit() {
return () => {
unsubscribeResume();
};
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, micPermissionDenied]);
// Subscribe to upstream-input-track-end events from AudioManager whenever a
// room is connected. The published mic track is a clone of a WebAudio
@@ -60,6 +60,16 @@ export interface VisualViewportInset {
value: string;
/** True if the soft keyboard is occluding the bottom of the layout viewport. */
keyboardOpen: boolean;
/**
* True if a text-entry element currently has focus. Used as a focus-based
* fallback signal for the iOS PWA case where `interactive-widget=resizes-content`
* (or iOS's native standalone behavior) shrinks the layout viewport itself
* for the keyboard — `vv.height` ends up matching `innerHeight`, so
* `keyboardOpen` (which infers from height delta) stays false even though
* the keyboard IS up. Consumers that need a "is the keyboard most likely
* up" signal should OR `keyboardOpen || textInputFocused`.
*/
textInputFocused: boolean;
/**
* Live `visualViewport.height` in pixels, or `null` if `visualViewport` is
* unavailable. Consumers that want to size a container to the visible
@@ -77,12 +87,16 @@ export interface VisualViewportInset {
const FALLBACK: VisualViewportInset = {
value: 'env(safe-area-inset-bottom)',
keyboardOpen: false,
textInputFocused: false,
height: null,
offsetTop: null,
};
export function useVisualViewportInset(): VisualViewportInset {
const [inset, setInset] = useState<VisualViewportInset>(FALLBACK);
// Mutable ref so `measure()` reads the latest focus state without React
// re-renders racing the visualViewport update path.
const textInputFocusedRef = { current: false } as { current: boolean };
useEffect(() => {
const vv = window.visualViewport;
@@ -104,12 +118,14 @@ export function useVisualViewportInset(): VisualViewportInset {
? {
value: `${Math.round(occlusion)}px`,
keyboardOpen: true,
textInputFocused: textInputFocusedRef.current,
height: vv.height,
offsetTop: vv.offsetTop,
}
: {
value: 'env(safe-area-inset-bottom)',
keyboardOpen: false,
textInputFocused: textInputFocusedRef.current,
height: vv.height,
offsetTop: vv.offsetTop,
};
@@ -117,9 +133,24 @@ export function useVisualViewportInset(): VisualViewportInset {
// Functional update + shallow compare so identical re-measurements
// don't churn React state every animation frame during keyboard
// transitions.
//
// ALL returned fields must be in this comparison, otherwise an
// observable change to one of them is silently dropped — the historical
// bug was `textInputFocused` being absent here. On iOS PWA standalone
// the layout viewport is shrunk natively for the keyboard, so
// `vv.height` matches `innerHeight` and `keyboardOpen`/`value` stay
// unchanged across the transition. The only signal that flips is
// `textInputFocused`. Without it in the compare, consumers
// (`MessageInput`'s `--composer-clearance` effect, the composer's
// inline `bottom` style) never see the focus change propagate, the
// composer stayed pinned above the home indicator while the keyboard
// was actually up, and on close the clearance variable was computed
// off a stale `bottom` value — surfacing as a -4 px overlap between
// the last message and the composer's top edge.
setInset((prev) =>
prev.value === next.value &&
prev.keyboardOpen === next.keyboardOpen &&
prev.textInputFocused === next.textInputFocused &&
prev.height === next.height &&
prev.offsetTop === next.offsetTop
? prev
@@ -187,6 +218,11 @@ export function useVisualViewportInset(): VisualViewportInset {
tag === 'TEXTAREA' ||
(t as HTMLElement).isContentEditable === true;
if (!editable) return;
// Track focus state — used as a fallback signal in the iOS PWA case
// where `vv.height` doesn't shrink for the keyboard (because iOS
// native-shifts the layout viewport instead). `focusin` → focused;
// `focusout` → unfocused. Capture phase listener so we see all events.
textInputFocusedRef.current = e.type === 'focusin';
// Immediate measure + a polling window for laggy iOS PWA event flows.
update();
startPolling(600);
+22
View File
@@ -135,6 +135,16 @@ interface VoiceState {
clearVoiceUsersForOrigin: (origin: string) => void;
leaveVoice: () => void;
handleForceDisconnect: () => void;
// Mic permission state — set when getUserMedia rejects with NotAllowedError
// (most commonly in iOS PWA standalone, where the gesture-window discipline
// is strict). Consumers (`useLiveKit` syncMic) skip publishing the mic
// track when this is true; the user appears in voice as a connected
// participant who can hear others but is effectively muted at the source
// (no track ever published, server cannot un-mute remotely). UI surfaces
// a "Grant microphone access" affordance to retry from a fresh user
// gesture; on success the flag clears and `useLiveKit` republishes.
micPermissionDenied: boolean;
setMicPermissionDenied: (denied: boolean) => void;
// Gesture-aware connect/disconnect refs — registered by AppLayout from useLiveKit()
connectFn: ((channelId: string, isDm?: boolean) => Promise<void>) | null;
disconnectFn: (() => Promise<void>) | null;
@@ -153,6 +163,7 @@ export const useVoiceStore = create<VoiceState>()(
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
micPermissionDenied: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
@@ -533,6 +544,8 @@ export const useVoiceStore = create<VoiceState>()(
spaceMutedUserIds: new Set(),
spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
// Mic permission gate
micPermissionDenied: false,
}),
clearVoiceUsersForOrigin: (origin: string) => {
@@ -589,6 +602,10 @@ export const useVoiceStore = create<VoiceState>()(
unwatchedCameras: new Set(),
streamWatchers: new Map(),
voiceUsers,
// Reset mic permission flag on leave so the next join attempts a
// fresh getUserMedia (the user may have granted permission via
// OS settings while disconnected).
micPermissionDenied: false,
};
});
},
@@ -599,6 +616,9 @@ export const useVoiceStore = create<VoiceState>()(
setConnectFn: (fn) => set({ connectFn: fn }),
setDisconnectFn: (fn) => set({ disconnectFn: fn }),
// Mic permission state setter — see interface comment.
setMicPermissionDenied: (denied) => set({ micPermissionDenied: denied }),
// Force disconnect: clear local connection state but do NOT touch voiceUsers.
// Used for involuntary disconnects (identity collision, server shutdown, etc.)
// where the server is the authority on who's actually in voice.
@@ -628,6 +648,7 @@ export const useVoiceStore = create<VoiceState>()(
watchingStreams: new Set(),
unwatchedCameras: new Set(),
streamWatchers: new Map(),
micPermissionDenied: false,
});
},
@@ -669,6 +690,7 @@ export const useVoiceStore = create<VoiceState>()(
spaceMutedUserIds: new Set(),
spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
micPermissionDenied: false,
}),
}),
{
+115
View File
@@ -1,6 +1,8 @@
import { useVoiceStore } from '../stores/voiceStore';
import { getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from '../hooks/useWebSocket';
import { AudioManager } from '../audio/AudioManager';
import { useUIStore } from '../stores/uiStore';
// ---------------------------------------------------------------------------
// Effective-state helpers — single source of truth for broadcasts
@@ -66,6 +68,30 @@ export function broadcastDeafenViaLiveKit(): void {
* this sends an explicit voice_leave to Instance A first so it
* broadcasts a leave event and the client cleans up stale voice state.
*
* **iOS user-gesture discipline.** `getUserMedia({audio:…})` is fired
* synchronously here (before any await crosses the gesture boundary) so
* iOS Safari surfaces the microphone permission prompt immediately on
* the user's tap. The previous flow only acquired the mic in the
* `useLiveKit` `syncMic` effect, which fires AFTER `room.connect()`
* (token fetch + WS handshake) completes — many awaits past the
* activation window. iOS PWA standalone is especially strict and would
* silently never surface the prompt; the user appeared stuck on
* "Waiting for others to join…" until they locked/unlocked the device,
* which iOS treats as a fresh activation context that finally allowed
* the queued prompt to surface.
*
* **Denial path.** If the user denies the prompt (NotAllowedError),
* `voiceStore.micPermissionDenied` is set to true and we proceed with
* the LiveKit connect anyway. The user appears in the voice channel
* normally, can hear other participants, but no microphone track is
* ever published — `useLiveKit.syncMic` skips the publish branch when
* the flag is set. UI surfaces a "Grant microphone access" affordance
* (`MobileVoiceFullScreen`, `VoiceControlBar`) that retries
* `getUserMedia` from a fresh user gesture; on success the flag clears
* and `useLiveKit.republishMicrophone` is called directly. The flag
* resets to `false` automatically on `leaveVoice()` /
* `handleForceDisconnect()` so a rejoin attempts a fresh prompt.
*
* @param channelId The channel to join.
* @param connectFn The LiveKit connect function, obtained from
* `useVoiceStore.getState().connectFn`. When provided the
@@ -96,6 +122,49 @@ export function joinVoiceChannel(
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId);
// Pre-arm the microphone INSIDE the user-gesture context. This must
// happen before `connectFn` so the call to `setInputDevice` (which is
// routed through `inputSwitchChain.then(...)` and ends in
// `getUserMedia`) is invoked while the activation window is still open.
// Reset the prior denial flag so a re-attempt isn't pre-vetoed by
// syncMic, and clear AudioManager's cached denial so the next
// `getUserMedia` actually fires (rather than re-throwing the cached
// error from a previous denial in this session).
const voiceState = useVoiceStore.getState();
voiceState.setMicPermissionDenied(false);
const audioManager = AudioManager.getInstance();
audioManager.clearInputDenial();
// Resume the AudioContext synchronously inside the gesture too — iOS
// requires `AudioContext.resume()` to be invoked from a user
// activation. Fire-and-forget; `useLiveKit.connect` also calls this
// and will await the same context.
audioManager.resumeContext().catch((err) => {
console.warn('[voice] AudioContext resume failed:', err);
});
// Fire-and-forget. `setInputDevice` is internally serialized via
// `inputSwitchChain` so the later syncMic call short-circuits to the
// already-acquired stream rather than re-prompting. On denial we
// record the flag — syncMic will then skip the publish branch.
audioManager.setInputDevice(voiceState.inputDeviceId).catch((err: unknown) => {
const name = err instanceof Error ? err.name : '';
if (name === 'NotAllowedError') {
useVoiceStore.getState().setMicPermissionDenied(true);
useUIStore.getState().addToast(
'Microphone access denied. You joined as a listener — tap "Allow microphone" to grant access.',
'warning',
);
} else if (name === 'NotFoundError') {
// No mic hardware available. Proceed as listener.
useVoiceStore.getState().setMicPermissionDenied(true);
useUIStore.getState().addToast(
'No microphone detected. You joined as a listener.',
'info',
);
} else {
console.error('[voice] Mic pre-arm failed:', err);
}
});
// Direct connection within gesture context
if (connectFn) {
connectFn(channelId).catch((err) => {
@@ -105,3 +174,49 @@ export function joinVoiceChannel(
});
}
}
/**
* Re-attempt microphone permission acquisition after a previous denial.
* Must be called from a user-gesture handler (button click, etc.) for iOS
* Safari to actually surface the permission prompt. On success, clears the
* `micPermissionDenied` flag and the next `useLiveKit` syncMic tick (or an
* external `republishMicrophone` call) publishes the freshly acquired
* stream.
*
* Returns `true` when the mic was acquired, `false` on any error
* (NotAllowedError, NotFoundError, etc.).
*/
export async function requestMicPermission(): Promise<boolean> {
const audioManager = AudioManager.getInstance();
const inputDeviceId = useVoiceStore.getState().inputDeviceId;
// Clear AudioManager's cached denial so the next `setInputDevice` call
// actually fires `getUserMedia` instead of re-throwing the cached error.
audioManager.clearInputDenial();
try {
// Resume context first — iOS may have suspended it during the denied
// state.
await audioManager.resumeContext();
await audioManager.setInputDevice(inputDeviceId);
useVoiceStore.getState().setMicPermissionDenied(false);
return true;
} catch (err: unknown) {
const name = err instanceof Error ? err.name : '';
if (name === 'NotAllowedError') {
useUIStore.getState().addToast(
'Microphone permission still denied. Open Settings → Safari to grant access.',
'warning',
);
} else if (name === 'NotFoundError') {
useUIStore.getState().addToast(
'No microphone detected.',
'warning',
);
} else {
useUIStore.getState().addToast(
'Could not access the microphone.',
'warning',
);
}
return false;
}
}