feat(mobile): Wave 5 — voice-join camera preview + Electron settings entries + toast positioning
Closes the mobile parity push. - MobileVoiceJoinSheet: full pre-join camera preview. Dormant-by-default (never auto-fires getUserMedia), explicit user gesture to arm, hard-bound disarm on sheet close (any path). Camera picker popup portaled to document.body so a long device list stays scrollable above the aspect-video preview tile (max-height min(50vh,320px), iOS scroll momentum). iOS Safari-safe: autoPlay playsInline muted + post-await play(). - MobileSettingsScreen: Keybinds + Desktop sections gated on isElectron(); they reuse the existing KeybindsPanel/DesktopPanel which are already mobile-fit. screenMap entries added in MobileShell. (Verify on Electron desktop build at narrow viewport.) - ToastContainer: real overlap was hiding the voice-fullscreen control bar. Mobile branch now reads isMobile/mobileStack/currentVoiceChannelId and computes the bottom offset across five mobile states (voice-full / pushed+voice / pushed / root+voice / root), all with safe-area-inset-bottom; left-3 right-3 + items-center keeps toasts in the safe-tap zone. Desktop bottom-6 right-6 unchanged. Specs: docs/systems/mobile-ui.md (Toast Positioning section, screenMap rows, Electron-entry rationale, z-index row); docs/systems/voice.md (Mobile pre-join preview subsection covering lifecycle + camera picker portal).
This commit is contained in:
@@ -312,6 +312,8 @@ Event options: `touchstart` is `{ passive: true }`, `touchmove` is `{ passive: f
|
||||
| `settings-voice` | `MobileSettingsScreen` | `initialPanel="voice"` |
|
||||
| `settings-privacy` | `MobileSettingsScreen` | `initialPanel="privacy"` |
|
||||
| `settings-connections` | `MobileSettingsScreen` | `initialPanel="connections"` |
|
||||
| `settings-keybinds` | `MobileSettingsScreen` | `initialPanel="keybinds"` (Electron-only entry; map row always present) |
|
||||
| `settings-desktop` | `MobileSettingsScreen` | `initialPanel="desktop"` (Electron-only entry; map row always present) |
|
||||
| `settings-instance` | `MobileInstancePanel` | — |
|
||||
| `settings-instance-general` | `GeneralPanel` (wrapped) | — |
|
||||
| `settings-instance-registration` | `RegistrationPanel` (wrapped) | — |
|
||||
@@ -392,8 +394,14 @@ Params: `{ channelId, spaceId }`
|
||||
|
||||
Two modes controlled by `initialPanel` prop:
|
||||
|
||||
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Video, Privacy, Connections, Instance for admins). Each pushes `settings-{id}`.
|
||||
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel) directly with a back header.
|
||||
1. **Hub mode** (`initialPanel` undefined): List of setting sections (Account, Voice & Video, Privacy, Connections, Keybinds + Desktop when in Electron, Instance for admins). Each pushes `settings-{id}`.
|
||||
2. **Direct panel mode** (`initialPanel` set): Renders the corresponding panel component (AccountPanel, VoicePanel, PrivacyPanel, ConnectionsPanel, KeybindsPanel, DesktopPanel) directly with a back header.
|
||||
|
||||
**Electron-only entries.** The Keybinds and Desktop sections appear in the hub list only when `isElectron() === true` (mirrors the desktop `UserSettings` modal's gate on `DesktopPanel`). Rationale:
|
||||
- `DesktopPanel` exposes auto-launch, app-version + update check, and "Change Instance" — all of which call `window.backspace.*` IPC and are meaningless on web/iOS PWA.
|
||||
- `KeybindsPanel`'s value comes from the desktop app's `uiohook-napi`-backed global keybind manager. The web fallback (only-when-tab-focused, no global hooks, no recording flow on touch keyboards) has no useful surface for a phone-shaped viewport. Showing the panel anyway would mislead a mobile-web user into recording a binding that can never fire.
|
||||
|
||||
Both panels are mobile-fit at 360-390px viewports (single-column rows with `flex justify-between`, `min-w-0` on labels, small tap-target buttons). The gate is therefore a list-visibility decision, not a layout decision — once a desktop user happens to be on a narrow viewport (split-window, dock, etc.), the panels render correctly.
|
||||
|
||||
### MobileInstancePanel
|
||||
|
||||
@@ -568,11 +576,32 @@ The root MobileShell uses `height: 100dvh` (dynamic viewport height) to account
|
||||
| MobileNav backdrop | `z-[35]` | MobileNav sidebar overlay |
|
||||
| MobileNav hamburger | `z-[120]` | MobileNav toggle button |
|
||||
| DMs FAB | `z-20` | MobileDmsScreen new DM button |
|
||||
| Toast container | `z-[300]` | ToastContainer (positioning differs by mobile state — see Toast Positioning) |
|
||||
| Bottom sheets (backdrop) | `z-[300]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||
| Bottom sheets (content) | `z-[301]` | MobileFolderSheet, Add Space sheet, ContextMenu |
|
||||
|
||||
---
|
||||
|
||||
## Toast Positioning
|
||||
|
||||
`packages/web/src/components/ui/ToastContainer.tsx` is a single shared component. On desktop it renders at `bottom-6 right-6` (anchored bottom-right). On mobile the container is repositioned to clear the bottom chrome and center horizontally so toasts don't get cropped against narrow viewports or hidden behind voice/nav controls.
|
||||
|
||||
The mobile bottom offset is computed via `resolveMobileBottomOffset(hasStack, topScreen, inVoice)` and added to `env(safe-area-inset-bottom)`:
|
||||
|
||||
| Mobile State | Bottom Offset (above `safe-area-inset-bottom`) | Rationale |
|
||||
|---|---|---|
|
||||
| `topScreen === 'voice-full'` | `72px + 12px` | Clears `MobileVoiceFullScreen` control bar (5 round buttons in `glass-bubble` with `mb-2`); bottom nav + mini-bar hidden in this mode |
|
||||
| Stack non-empty + in voice | `64px + 12px` | Clears `MobileVoiceMiniBar` (sits above the stacked screen since the bottom nav is hidden when stack non-empty) |
|
||||
| Stack non-empty + no voice | `12px` | Pushed screens have no bottom nav and no mini-bar |
|
||||
| Root tab + in voice | `56px + 64px + 12px` | Clears `MobileBottomNav` (56px) + `MobileVoiceMiniBar` (~64px) stacked above |
|
||||
| Root tab + no voice | `56px + 12px` | Clears `MobileBottomNav` only |
|
||||
|
||||
On mobile the container also uses `left-3 right-3` + `items-center` instead of `right-6` so toasts center horizontally with `max-w-[320px]`. This avoids horizontal overlap with bottom-bar controls (which span the full mobile width via `mx-2`) and stays inside the safe-tap zone on narrow screens.
|
||||
|
||||
The container subscribes to `useUIStore.isMobile`, `useUIStore.mobileStack`, and `useVoiceStore.currentVoiceChannelId`, so the offset re-computes reactively whenever any of those change — no manual repositioning needed when the user enters/exits voice or pushes/pops a screen while a toast is on screen.
|
||||
|
||||
---
|
||||
|
||||
## LocalStorage Persistence
|
||||
|
||||
The uiStore uses `zustand/persist` with `partialize`:
|
||||
|
||||
@@ -385,6 +385,18 @@ Mode is reactive on `isCameraOn` changes. Pre-call streams stop on tab hide (`vi
|
||||
|
||||
**Privacy: dormant-by-default.** The pre-call mode never auto-starts. On section mount, `navigator.permissions.query({ name: 'camera' as PermissionName })` reports the permission state without firing the camera. The preview tile is dormant (placeholder + "Click to test camera" overlay) until the user explicitly clicks it, or until the prompt-state CTA button triggers `getUserMedia` (which both grants permission and opens preview in one step). Rationale: macOS holds the camera LED on for ~2s after release, so any incidental `getUserMedia` call (probe, transient mount) flashes the LED — a privacy/UX defect. The only entry points to `getUserMedia` are explicit user gestures: dormant-tile click, prompt CTA, "Try again" in the denied banner, and dropdown change while preview is already running.
|
||||
|
||||
### Mobile pre-join preview (in `MobileVoiceJoinSheet.tsx`)
|
||||
The mobile bottom-sheet voice-join flow exposes the same dormant-by-default camera preview pattern as `VideoSection`'s pre-call mode. When the user taps a voice channel on `MobileSpacesScreen`, the join sheet opens with a 16:9 preview tile. The tile starts dormant ("Tap to preview camera") — never auto-fires `getUserMedia`. Tapping the tile, the prompt-state CTA, or "Try again" after a denial calls `getUserMedia({ video: { deviceId: ... } })` with the user's persisted `cameraDeviceId` from `voiceStore`.
|
||||
|
||||
Lifecycle is hard-bound to the sheet:
|
||||
- **Arm:** explicit user tap inside the sheet (any of the entry-point buttons).
|
||||
- **Disarm:** sheet close (any path: backdrop tap, close button, channel switch, Join Voice tap which transitions to the in-call flow). The single source of truth for "camera off when sheet closes" is the cleanup effect on the component's unmount — the parent (`MobileSpacesScreen`) removes the sheet, the cleanup runs `stopPreview()`, and tracks are stopped + `srcObject` cleared.
|
||||
- **Tab-hide:** matches `VideoSection` — release on `visibilitychange === 'hidden'`, no auto-resume; user must re-tap.
|
||||
- **Camera switch:** when multiple cameras are present, a picker overlay in the bottom-left of the tile lets the user swap. The picker is gated on `permState === 'granted' && cameraDevices.length > 1` so it doesn't appear for single-camera devices. Switching cameras while preview is running re-opens `getUserMedia` for the new `deviceId`; `cameraDeviceId` is shared with `voiceStore` so the selection persists into the call.
|
||||
- **Picker popup is portaled to `document.body`.** The trigger button sits inside the `aspect-video overflow-hidden` preview tile, but the dropdown list is rendered as a `position: fixed` element via `createPortal` so it can extend above the tile. Position is captured from the trigger's `getBoundingClientRect()` (re-captured on `resize` / capturing `scroll`) and pinned via `bottom = window.innerHeight - rect.top + 4` so the popup expands upward. The list has `max-height: min(50vh, 320px)`, `overflow-y: auto`, and `-webkit-overflow-scrolling: touch` so every entry stays reachable on a long device list. Click-outside dismissal listens for both `mousedown` and `touchstart`, and excludes both the anchor and the portaled popup (the popup is not a DOM descendant of the anchor since it lives in `document.body`).
|
||||
|
||||
The `<video>` element is set up identically to `VideoSection` for iOS Safari compatibility: `autoPlay playsInline muted` attributes on the element, `srcObject` set after the `await getUserMedia`, and a defensive `videoEl.play().catch(() => {})`. iOS Safari requires `autoPlay` because the user-gesture context expires across the await — `play()` alone fails silently.
|
||||
|
||||
### Architectural asymmetry: mic republishes, camera switches
|
||||
Mic publishes the output of a Web Audio graph (RNNoise, gain, AEC) — `LocalParticipant.switchActiveDevice` cannot operate on it because the published track is a `MediaStreamAudioDestinationNode.stream`'s track, not a raw mic track. Mic device changes therefore unpublish/republish via `AudioManager.getFreshTrack()`. Camera publishes the raw `getUserMedia` track and uses `switchActiveDevice` for in-place swaps. **Do not unify.**
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import { AccountPanel } from '../modals/settingsPanels/AccountPanel';
|
||||
import { VoicePanel } from '../modals/settingsPanels/VoicePanel';
|
||||
import { ConnectionsPanel } from '../modals/settingsPanels/ConnectionsPanel';
|
||||
import { PrivacyPanel } from '../modals/settingsPanels/PrivacyPanel';
|
||||
import { KeybindsPanel } from '../modals/settingsPanels/KeybindsPanel';
|
||||
import { DesktopPanel } from '../modals/settingsPanels/DesktopPanel';
|
||||
import { MobileScreenHeader } from './MobileScreenHeader';
|
||||
import { TransferIndicator } from './TransferIndicator';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
|
||||
interface MobileSettingsScreenProps {
|
||||
initialPanel?: string;
|
||||
@@ -17,6 +20,8 @@ const panelConfig: Record<string, { title: string; component: React.ReactNode }>
|
||||
voice: { title: 'Voice & Video', component: <VoicePanel /> },
|
||||
privacy: { title: 'Privacy', component: <PrivacyPanel /> },
|
||||
connections: { title: 'Connections', component: <ConnectionsPanel /> },
|
||||
keybinds: { title: 'Keybinds', component: <KeybindsPanel /> },
|
||||
desktop: { title: 'Desktop', component: <DesktopPanel /> },
|
||||
};
|
||||
|
||||
const sectionIcons: Record<string, React.ReactNode> = {
|
||||
@@ -45,6 +50,16 @@ const sectionIcons: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
),
|
||||
keybinds: (
|
||||
<svg className="w-5 h-5 text-txt-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
desktop: (
|
||||
<svg className="w-5 h-5 text-txt-secondary" 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>
|
||||
),
|
||||
};
|
||||
|
||||
export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps) {
|
||||
@@ -67,12 +82,18 @@ export function MobileSettingsScreen({ initialPanel }: MobileSettingsScreenProps
|
||||
);
|
||||
}
|
||||
|
||||
// Settings section list
|
||||
// Settings section list. Desktop and Keybinds are Electron-only — global
|
||||
// shortcuts and auto-launch/update controls are meaningless on the iOS PWA
|
||||
// and on web mobile. The desktop UserSettings modal also gates Desktop on
|
||||
// isElectron(); we mirror that here, plus apply the same gate to Keybinds
|
||||
// since the panel's only-when-tab-focused web fallback isn't a useful
|
||||
// mobile feature (no global hooks, no recording flow on touch keyboards).
|
||||
const sections = [
|
||||
{ id: 'account', label: 'Account' },
|
||||
{ id: 'voice', label: 'Voice & Video' },
|
||||
{ id: 'privacy', label: 'Privacy' },
|
||||
{ id: 'connections', label: 'Connections' },
|
||||
...(isElectron() ? [{ id: 'keybinds', label: 'Keybinds' }, { id: 'desktop', label: 'Desktop' }] : []),
|
||||
...(isAdmin ? [{ id: 'instance', label: 'Instance' }] : []),
|
||||
];
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
|
||||
'settings-voice': () => <MobileSettingsScreen initialPanel="voice" />,
|
||||
'settings-privacy': () => <MobileSettingsScreen initialPanel="privacy" />,
|
||||
'settings-connections': () => <MobileSettingsScreen initialPanel="connections" />,
|
||||
'settings-keybinds': () => <MobileSettingsScreen initialPanel="keybinds" />,
|
||||
'settings-desktop': () => <MobileSettingsScreen initialPanel="desktop" />,
|
||||
'settings-instance': () => <MobileInstancePanel />,
|
||||
'settings-instance-general': () => (
|
||||
<div className="flex flex-col h-full bg-surface-base">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
|
||||
const borderColors = {
|
||||
info: 'border-l-accent-sky',
|
||||
@@ -7,14 +8,90 @@ const borderColors = {
|
||||
success: 'border-l-accent-mint',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the bottom-edge offset for the toast stack on mobile.
|
||||
*
|
||||
* Mobile layout has up to three pieces of bottom chrome that toasts must
|
||||
* clear, depending on what's visible:
|
||||
*
|
||||
* - Bottom nav (`MobileBottomNav`): 56px + safe-area-inset-bottom — visible
|
||||
* when the screen stack is empty.
|
||||
* - Voice mini-bar (`MobileVoiceMiniBar`): ~56px on top of whatever sits
|
||||
* below it — visible whenever a voice channel is connected and `voice-full`
|
||||
* is NOT the topmost screen.
|
||||
* - Voice fullscreen control bar (`MobileVoiceFullScreen`): ~72px including
|
||||
* its `mb-2 + safe-area-inset-bottom` — visible only when `voice-full` is
|
||||
* the topmost screen, and the bottom nav + mini-bar are both hidden in
|
||||
* that mode.
|
||||
*
|
||||
* Returns a CSS bottom offset value (string with units) that lifts the toast
|
||||
* stack above whichever chrome is currently rendered, plus an extra 12px of
|
||||
* breathing room.
|
||||
*/
|
||||
function resolveMobileBottomOffset(
|
||||
hasStack: boolean,
|
||||
topScreen: string | null,
|
||||
inVoice: boolean,
|
||||
): string {
|
||||
// Voice fullscreen is on top: clear its control bar (mx-2 mb-2 round bar
|
||||
// with safe-area inset). Bottom nav + mini-bar are hidden in this mode.
|
||||
if (topScreen === 'voice-full') {
|
||||
return 'calc(72px + 12px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
|
||||
// Stack non-empty (some pushed screen other than voice-full): bottom nav is
|
||||
// hidden. Mini-bar is visible iff in voice.
|
||||
if (hasStack) {
|
||||
if (inVoice) {
|
||||
// Mini-bar (~56px + mb-1) sits at bottom alone.
|
||||
return 'calc(64px + 12px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
return 'calc(12px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
|
||||
// Root tab (no stack). Bottom nav is visible. Mini-bar may also be present
|
||||
// above it.
|
||||
if (inVoice) {
|
||||
return 'calc(56px + 64px + 12px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
return 'calc(56px + 12px + env(safe-area-inset-bottom))';
|
||||
}
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useUIStore((s) => s.toasts);
|
||||
const removeToast = useUIStore((s) => s.removeToast);
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const mobileStack = useUIStore((s) => s.mobileStack);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
const topScreen =
|
||||
mobileStack.length > 0 ? mobileStack[mobileStack.length - 1]?.screen ?? null : null;
|
||||
const inVoice = currentVoiceChannelId !== null;
|
||||
|
||||
// Mobile: lift above bottom chrome and center horizontally so the toast
|
||||
// doesn't get cropped by `right-6` against narrow viewports. Desktop:
|
||||
// keep the existing `bottom-6 right-6` anchor.
|
||||
const containerStyle: React.CSSProperties = isMobile
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: '12px',
|
||||
right: '12px',
|
||||
bottom: resolveMobileBottomOffset(mobileStack.length > 0, topScreen, inVoice),
|
||||
zIndex: 300,
|
||||
}
|
||||
: { position: 'fixed', bottom: '24px', right: '24px', zIndex: 300 };
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-2 pointer-events-none">
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? 'flex flex-col gap-2 pointer-events-none items-center'
|
||||
: 'flex flex-col gap-2 pointer-events-none'
|
||||
}
|
||||
style={containerStyle}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { VoiceUserRow } from './VoiceUserRow';
|
||||
|
||||
/**
|
||||
* Permission state for the in-sheet camera preview. Mirrors the smaller
|
||||
* version of the state machine in `VideoSection.tsx` — but pre-join we treat
|
||||
* `denied` and `hard-blocked` the same (single retry path; user can still join
|
||||
* voice without preview).
|
||||
*/
|
||||
type CameraPermState = 'unknown' | 'granted' | 'prompt' | 'denied';
|
||||
|
||||
function errorName(err: unknown): string {
|
||||
return err instanceof Error ? err.name : '';
|
||||
}
|
||||
|
||||
interface MobileVoiceJoinSheetProps {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
@@ -29,10 +41,48 @@ export function MobileVoiceJoinSheet({
|
||||
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
|
||||
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
|
||||
const speakingUserIds = useVoiceStore((s) => s.speakingUserIds);
|
||||
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const channels = useSpaceStore((s) => s.channels);
|
||||
|
||||
// ── Camera preview state (pre-join) ─────────────────────────────────────
|
||||
// Mirrors `VideoSection.tsx`'s explicit-gesture pattern. We never fire
|
||||
// getUserMedia on mount — only on user-tap of the "Enable preview" CTA or
|
||||
// dropdown re-select while preview is already active. The preview is hard-
|
||||
// bound to the sheet lifecycle: closing the sheet (any path: backdrop tap,
|
||||
// close button, Join, channel-switch) stops the stream and releases the LED.
|
||||
const [permState, setPermState] = useState<CameraPermState>('unknown');
|
||||
const [previewActive, setPreviewActive] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [cameraDevices, setCameraDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const previewStreamRef = useRef<MediaStream | null>(null);
|
||||
const startGenRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const pickerAnchorRef = useRef<HTMLDivElement>(null);
|
||||
// Portaled-popup ref. The popup is rendered into document.body to escape the
|
||||
// sheet's `aspect-video overflow-hidden` parent (which clipped the top of the
|
||||
// list when the device list was long). Click-outside checks both the anchor
|
||||
// and this portaled popup so taps on list items don't dismiss it.
|
||||
const pickerPopupRef = useRef<HTMLDivElement>(null);
|
||||
const [pickerPopupRect, setPickerPopupRect] = useState<{ left: number; bottom: number } | null>(null);
|
||||
|
||||
// Stop the active preview stream (releases the camera LED). macOS holds the
|
||||
// LED on for ~2s after release (hardware debounce).
|
||||
const stopPreview = useCallback(() => {
|
||||
if (previewStreamRef.current) {
|
||||
previewStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||
previewStreamRef.current = null;
|
||||
}
|
||||
const videoEl = previewVideoRef.current;
|
||||
if (videoEl && videoEl.srcObject instanceof MediaStream) {
|
||||
videoEl.srcObject = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Trigger slide-up animation on mount
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -42,6 +92,212 @@ export function MobileVoiceJoinSheet({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Mount-time camera permission probe. `permissions.query` does not light the
|
||||
// LED; getUserMedia is gated behind explicit user gestures only. If the
|
||||
// Permissions API is missing or rejects 'camera', we fall back to `prompt`
|
||||
// so the user can still trigger the preview manually.
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let cancelled = false;
|
||||
let status: PermissionStatus | null = null;
|
||||
let onChange: (() => void) | null = null;
|
||||
|
||||
const apply = (state: PermissionState) => {
|
||||
if (cancelled || !mountedRef.current) return;
|
||||
if (state === 'granted') setPermState('granted');
|
||||
else if (state === 'prompt') setPermState('prompt');
|
||||
else setPermState('denied');
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!navigator.permissions || typeof navigator.permissions.query !== 'function') {
|
||||
if (!cancelled && mountedRef.current) setPermState('prompt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const s = await navigator.permissions.query({ name: 'camera' as PermissionName });
|
||||
status = s;
|
||||
apply(s.state);
|
||||
onChange = () => apply(s.state);
|
||||
s.addEventListener('change', onChange);
|
||||
} catch {
|
||||
if (!cancelled && mountedRef.current) setPermState('prompt');
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
if (status && onChange) {
|
||||
try {
|
||||
status.removeEventListener('change', onChange);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Enumerate cameras when permission is granted; refresh on devicechange.
|
||||
// Passive — does not light the LED.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
|
||||
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);
|
||||
};
|
||||
}, [permState]);
|
||||
|
||||
// Hard cleanup: when the sheet unmounts (any close path), stop the stream
|
||||
// and release the camera. This is the single source of truth for "camera
|
||||
// off when sheet closes" — every close handler funnels through unmount via
|
||||
// the parent removing the component.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
startGenRef.current += 1;
|
||||
stopPreview();
|
||||
};
|
||||
}, [stopPreview]);
|
||||
|
||||
// Tab-visibility cleanup: release LED when tab is hidden. Spec choice (same
|
||||
// as VideoSection): no auto-resume — user must re-tap to re-arm.
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden' && previewActive) {
|
||||
startGenRef.current += 1;
|
||||
stopPreview();
|
||||
setPreviewActive(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibility);
|
||||
}, [previewActive, stopPreview]);
|
||||
|
||||
// Camera-device picker click-outside (mousedown + touchstart).
|
||||
// Both the anchor (in-tile button) AND the portaled popup are excluded — the
|
||||
// popup is rendered into document.body to escape the sheet's overflow-hidden
|
||||
// parent, so it isn't a DOM descendant of the anchor.
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
const onPointerDown = (e: MouseEvent | TouchEvent) => {
|
||||
const target = e.target as Node;
|
||||
const inAnchor = pickerAnchorRef.current?.contains(target) ?? false;
|
||||
const inPopup = pickerPopupRef.current?.contains(target) ?? false;
|
||||
if (!inAnchor && !inPopup) setPickerOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('touchstart', onPointerDown, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('touchstart', onPointerDown);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
|
||||
// When the picker opens, capture the anchor's screen position so we can
|
||||
// portal the popup to document.body just above it. Using `bottom` keeps the
|
||||
// popup pinned to the top of the anchor's frame (so it expands upward and
|
||||
// is naturally constrained by `max-height + overflow-y-auto`).
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) {
|
||||
setPickerPopupRect(null);
|
||||
return;
|
||||
}
|
||||
const anchorBtn = pickerAnchorRef.current?.querySelector('button');
|
||||
if (!anchorBtn) return;
|
||||
const update = () => {
|
||||
const r = anchorBtn.getBoundingClientRect();
|
||||
setPickerPopupRect({
|
||||
left: r.left,
|
||||
// distance from viewport bottom to anchor top, plus a small gap
|
||||
bottom: window.innerHeight - r.top + 4,
|
||||
});
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('scroll', update, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, true);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
|
||||
// When the user changes camera selection while preview is running, swap.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
if (!previewActive) return;
|
||||
|
||||
const gen = ++startGenRef.current;
|
||||
stopPreview();
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
|
||||
});
|
||||
if (gen !== startGenRef.current || !mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
previewStreamRef.current = stream;
|
||||
const videoEl = previewVideoRef.current;
|
||||
if (videoEl) {
|
||||
// iOS Safari: srcObject + autoPlay + muted + playsInline. The video
|
||||
// element below sets `autoPlay playsInline muted`, so this play() is
|
||||
// redundant on most browsers — but it makes Chrome desktop happy and
|
||||
// doesn't hurt iOS. (Captured in handoff Known Traps.)
|
||||
videoEl.srcObject = stream;
|
||||
videoEl.play().catch(() => {});
|
||||
}
|
||||
setPreviewError(null);
|
||||
} catch (err) {
|
||||
if (gen !== startGenRef.current || !mountedRef.current) return;
|
||||
const name = errorName(err);
|
||||
if (name === 'NotAllowedError') {
|
||||
setPermState('denied');
|
||||
setPreviewActive(false);
|
||||
} else if (name === 'NotReadableError') {
|
||||
setPreviewError('Camera is in use by another application.');
|
||||
} else if (name === 'OverconstrainedError') {
|
||||
setPreviewError('Selected camera is unavailable.');
|
||||
} else if (name === 'NotFoundError') {
|
||||
setPreviewError('No camera detected.');
|
||||
} else {
|
||||
setPreviewError('Could not start camera preview.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cameraDeviceId, previewActive, permState]);
|
||||
|
||||
const userIds = useMemo(() => voiceUsers.get(channelId) || [], [voiceUsers, channelId]);
|
||||
|
||||
const userCount = userIds.length;
|
||||
@@ -63,6 +319,63 @@ export function MobileVoiceJoinSheet({
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Explicit user gesture: open getUserMedia for the selected camera.
|
||||
const startPreviewFromUser = useCallback(async () => {
|
||||
setPreviewError(null);
|
||||
const gen = ++startGenRef.current;
|
||||
stopPreview();
|
||||
setPreviewActive(true);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
|
||||
});
|
||||
if (gen !== startGenRef.current || !mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
previewStreamRef.current = stream;
|
||||
const videoEl = previewVideoRef.current;
|
||||
if (videoEl) {
|
||||
videoEl.srcObject = stream;
|
||||
videoEl.play().catch(() => {});
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setPermState((prev) => (prev === 'prompt' || prev === 'denied' ? 'granted' : prev));
|
||||
}
|
||||
} catch (err) {
|
||||
if (gen !== startGenRef.current || !mountedRef.current) return;
|
||||
const name = errorName(err);
|
||||
if (name === 'NotAllowedError') {
|
||||
setPermState('denied');
|
||||
setPreviewActive(false);
|
||||
} else if (name === 'NotReadableError') {
|
||||
setPreviewError('Camera is in use by another application.');
|
||||
} else if (name === 'OverconstrainedError') {
|
||||
setPreviewError('Selected camera is unavailable.');
|
||||
} else if (name === 'NotFoundError') {
|
||||
setPreviewError('No camera detected.');
|
||||
} else {
|
||||
setPreviewError('Could not start camera preview.');
|
||||
}
|
||||
}
|
||||
}, [cameraDeviceId, stopPreview]);
|
||||
|
||||
const stopPreviewFromUser = useCallback(() => {
|
||||
startGenRef.current += 1;
|
||||
stopPreview();
|
||||
setPreviewActive(false);
|
||||
setPreviewError(null);
|
||||
}, [stopPreview]);
|
||||
|
||||
// Derived: do we have multiple cameras to expose a picker for?
|
||||
const showCameraPicker = permState === 'granted' && cameraDevices.length > 1;
|
||||
const selectedCameraLabel = useMemo(() => {
|
||||
if (cameraDeviceId === null) return 'Auto';
|
||||
const d = cameraDevices.find((c) => c.deviceId === cameraDeviceId);
|
||||
return d?.label || 'Selected camera';
|
||||
}, [cameraDeviceId, cameraDevices]);
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
@@ -99,9 +412,98 @@ export function MobileVoiceJoinSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera preview tile (pre-join) */}
|
||||
<div className="px-5 mb-3">
|
||||
<div className="rounded-xl bg-surface-base overflow-hidden relative aspect-video">
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
className={`w-full h-full object-cover ${previewActive && !previewError ? '' : 'invisible'}`}
|
||||
style={{ transform: 'scaleX(-1)' }}
|
||||
/>
|
||||
|
||||
{/* Dormant / prompt state — tap to enable */}
|
||||
{!previewActive && !previewError && permState !== 'denied' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPreviewFromUser}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary active:bg-white/[0.03] transition-colors"
|
||||
aria-label="Enable camera preview"
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" className="mb-1.5">
|
||||
<path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z" />
|
||||
</svg>
|
||||
<span className="text-xs">
|
||||
{permState === 'unknown' ? 'Checking camera…' : 'Tap to preview camera'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Denied state */}
|
||||
{!previewActive && !previewError && permState === 'denied' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary px-6 text-center gap-2">
|
||||
<span className="text-xs">Camera permission denied.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPreviewFromUser}
|
||||
className="text-[11px] px-3 py-1.5 rounded-md bg-surface-elevated text-txt-secondary"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{previewError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary px-6 text-center gap-2 bg-surface-base/90">
|
||||
<span className="text-xs">{previewError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPreviewFromUser}
|
||||
className="text-[11px] px-3 py-1.5 rounded-md bg-surface-elevated text-txt-secondary"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stop preview pill — visible while running */}
|
||||
{previewActive && !previewError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopPreviewFromUser}
|
||||
className="absolute top-2 right-2 rounded-md bg-black/60 text-white/90 text-[11px] px-2.5 py-1.5"
|
||||
>
|
||||
Stop preview
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Camera picker — only when permission granted AND multiple cameras.
|
||||
The trigger sits inside the `aspect-video overflow-hidden` tile;
|
||||
the popup portals to document.body so it can extend above the
|
||||
tile when the device list is long. */}
|
||||
{showCameraPicker && (
|
||||
<div ref={pickerAnchorRef} className="absolute bottom-2 left-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="rounded-md bg-black/60 text-white/90 text-[11px] px-2.5 py-1.5 flex items-center gap-1 max-w-[160px]"
|
||||
>
|
||||
<span className="truncate">{selectedCameraLabel}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className={`flex-shrink-0 transition-transform ${pickerOpen ? 'rotate-180' : ''}`}>
|
||||
<path d="M7 10l5 5 5-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User list */}
|
||||
{userCount > 0 && (
|
||||
<div className="max-h-60 overflow-y-auto px-5 mb-4">
|
||||
<div className="max-h-40 overflow-y-auto px-5 mb-4">
|
||||
<div className="space-y-1">
|
||||
{userIds.map((userId) => {
|
||||
const member = members.find((m) => m.userId === userId);
|
||||
@@ -143,7 +545,7 @@ export function MobileVoiceJoinSheet({
|
||||
|
||||
{/* Empty state */}
|
||||
{userCount === 0 && (
|
||||
<div className="px-5 mb-4 py-6 text-center">
|
||||
<div className="px-5 mb-4 py-3 text-center">
|
||||
<p className="text-sm text-txt-tertiary">No one is in this channel yet.</p>
|
||||
<p className="text-xs text-txt-tertiary/60 mt-1">Be the first to join!</p>
|
||||
</div>
|
||||
@@ -181,7 +583,7 @@ export function MobileVoiceJoinSheet({
|
||||
{isSwitching ? 'Switch Channel' : 'Join Voice'}
|
||||
</button>
|
||||
|
||||
{/* Chat button — navigates to associated text channel (placeholder for future) */}
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
|
||||
@@ -193,6 +595,59 @@ export function MobileVoiceJoinSheet({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Camera picker popup — portaled to document.body so it escapes the
|
||||
sheet's `aspect-video overflow-hidden` tile. Anchored to the trigger
|
||||
button via getBoundingClientRect (captured in the open effect above).
|
||||
`max-h` + `overflow-y-auto` + iOS scroll momentum keeps the list
|
||||
reachable even with many cameras; using `bottom` (not `top`) makes
|
||||
the popup expand upward from the anchor. */}
|
||||
{showCameraPicker && pickerOpen && pickerPopupRect && (
|
||||
<div
|
||||
ref={pickerPopupRef}
|
||||
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
|
||||
style={{
|
||||
left: pickerPopupRect.left,
|
||||
bottom: pickerPopupRect.bottom,
|
||||
minWidth: 160,
|
||||
maxWidth: 240,
|
||||
// Cap to leave room above the safe area / sheet header. Combined with
|
||||
// overflow-y-auto this guarantees every entry stays reachable
|
||||
// regardless of device count.
|
||||
maxHeight: 'min(50vh, 320px)',
|
||||
// iOS Safari scroll momentum
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCameraDeviceId(null);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
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"
|
||||
onClick={() => {
|
||||
setCameraDeviceId(d.deviceId);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user