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
@@ -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)]'