-
- {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 (
-
handleParticipantContextMenu(e, userId)}
- >
-
-
- {(showMuted || showDeafened) && (
-
- {showDeafened ? (
-
-
-
-
- ) : (
-
-
-
-
- )}
-
- )}
-
-
- {info.name}{isMe ? ' (You)' : ''}
-
-
- );
- })}
-
-
- {participantIds.length === 0 && (
-
- 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 && (
+
+
+
+
+
+
+ Microphone access denied
+
+
+ You're listening only — others can't hear you.
+
- )}
+
{
+ 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
+
+
+ )}
+
+ {/* Participant grid — VoiceGrid handles attach/detach, tap-to-focus,
+ screen-share tiles, mute overlays, context menus. Identical
+ rendering pipeline to desktop. */}
+
+
{/* Control bar */}
-
{/* Mute */}
-
-
- {isMuted && }
+
+
+ {isMuted && (
+
+ )}
@@ -272,36 +439,124 @@ export function MobileVoiceFullScreen() {
-
-
- {isDeafened && }
+
+
+ {isDeafened && (
+
+ )}
- {/* Camera */}
-
-
-
-
-
+ {/* 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. */}
+
+
+
+
+
+
+ {showCameraSwitcher && (
+
{
+ 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"
+ >
+
+ {/* Camera-flip icon: arrows around a camera */}
+
+
+
+
+ )}
+
- {/* 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. */}
-
-
+
+
@@ -309,12 +564,75 @@ export function MobileVoiceFullScreen() {
-
-
+
+
+
+ {/* 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(
+
+ 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)
+
+ {cameraDevices.map((d, i) => (
+ 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}`}
+
+ ))}
+
,
+ document.body,
+ )}
);
}
diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx
index 318cd091..fc866938 100644
--- a/packages/web/src/components/voice/StreamTile.tsx
+++ b/packages/web/src/components/voice/StreamTile.tsx
@@ -340,6 +340,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
return (
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
diff --git a/packages/web/src/hooks/useVisualViewportInset.ts b/packages/web/src/hooks/useVisualViewportInset.ts
index 80f3f6df..4b55acd7 100644
--- a/packages/web/src/hooks/useVisualViewportInset.ts
+++ b/packages/web/src/hooks/useVisualViewportInset.ts
@@ -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(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);
diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts
index 401c1d35..82b486db 100644
--- a/packages/web/src/stores/voiceStore.ts
+++ b/packages/web/src/stores/voiceStore.ts
@@ -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) | null;
disconnectFn: (() => Promise) | null;
@@ -153,6 +163,7 @@ export const useVoiceStore = create()(
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
+ micPermissionDenied: false,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
@@ -533,6 +544,8 @@ export const useVoiceStore = create()(
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()(
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()(
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()(
watchingStreams: new Set(),
unwatchedCameras: new Set(),
streamWatchers: new Map(),
+ micPermissionDenied: false,
});
},
@@ -669,6 +690,7 @@ export const useVoiceStore = create()(
spaceMutedUserIds: new Set(),
spaceDeafenedUserIds: new Set(),
permissionMutedUserIds: new Set(),
+ micPermissionDenied: false,
}),
}),
{
diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts
index 3fb9a35e..615b039d 100644
--- a/packages/web/src/utils/voice.ts
+++ b/packages/web/src/utils/voice.ts
@@ -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 {
+ 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;
+ }
+}