feat(settings/mobile): Voice & Video adaption + chat header username normalization

MobileChatScreen DM header now applies parseFederatedUsername and
useCanonicalUserView, matching MobileDmsScreen so federated users render
as 'realname' rather than 'realname@domain'.

Mobile Voice & Audio renamed to Voice & Video across MobileSettingsScreen
and MobileYouScreen — parity with desktop's VoicePanel title.

AudioInputSection and VideoSection picker click-outside now listens for
touchstart alongside mousedown so a single tap dismisses on iOS Safari
(which doesn't synthesize mousedown reliably from touch).

AudioOutputSection feature-detects HTMLMediaElement.setSinkId at module
load and returns null on unsupported platforms (notably iOS Safari).
Split into outer gate + inner body to keep Rules of Hooks intact;
VoicePanel's space-y-5 collapses cleanly with no visible hole.

VideoSection 'Stop preview' CTA gets responsive sizing (mobile:
always-visible 44 px tap target; desktop: original hover-reveal pill via
md: prefixes). The preview <video> gains autoPlay so iOS Safari starts
the stream when srcObject is assigned — manual videoEl.play() after an
awaited getUserMedia loses the user-gesture context on iOS and was
silently rejected by .catch(()=>{}), causing the black-preview bug.

voice.md updated for the iOS hide-when-unsupported policy and the
touch-close contract on device pickers.
This commit is contained in:
Jannis Braun
2026-05-05 23:24:03 +02:00
parent fc9a07523f
commit 899dc8b601
7 changed files with 104 additions and 25 deletions
@@ -23,16 +23,22 @@ export function AudioInputSection() {
const dropdownRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
// Click-outside-to-close.
// Click-outside-to-close. iOS Safari does not synthesize `mousedown` from
// touch reliably, so we listen for `touchstart` alongside `mousedown` to
// make the popover dismissable with a single tap on touch devices.
useEffect(() => {
if (!listOpen) return;
const onMouseDown = (e: MouseEvent) => {
const onPointerDown = (e: MouseEvent | TouchEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setListOpen(false);
}
};
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown, { passive: true });
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [listOpen]);
// Listen for AudioContext resume events so dependent effects re-trigger
@@ -4,7 +4,37 @@ import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices';
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
/**
* Feature-detect per-element output routing support. iOS Safari has zero
* support for `HTMLMediaElement.setSinkId` (and likewise no AudioContext
* variant); when both are absent, the OS routes audio (Bluetooth menu, etc.)
* and an in-app picker would be non-functional.
*
* Cached at module level since support cannot change within a page lifetime.
*/
let cachedPlatformSupportsSinkSelection: boolean | null = null;
function platformSupportsSinkSelection(): boolean {
if (cachedPlatformSupportsSinkSelection !== null) return cachedPlatformSupportsSinkSelection;
if (typeof window === 'undefined' || typeof HTMLMediaElement === 'undefined') {
cachedPlatformSupportsSinkSelection = false;
return false;
}
const supported = 'setSinkId' in HTMLMediaElement.prototype;
cachedPlatformSupportsSinkSelection = supported;
return supported;
}
export function AudioOutputSection() {
// Hard gate: if the browser cannot route audio per-element at all (iOS
// Safari has zero support for HTMLMediaElement.setSinkId), hide the entire
// section. Users adjust output via OS controls (Bluetooth menu, etc.).
// This check is module-level cached; for a given page lifetime the function
// always returns the same value, so hook order downstream is stable.
if (!platformSupportsSinkSelection()) return null;
return <AudioOutputSectionInner />;
}
function AudioOutputSectionInner() {
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
const outputVolume = useVoiceStore((s) => s.outputVolume);
@@ -26,16 +56,22 @@ export function AudioOutputSection() {
const [audioCtxGen, setAudioCtxGen] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
// Click-outside-to-close.
// Click-outside-to-close. Listens for both mousedown and touchstart so the
// popover dismisses with a single tap on touch devices (iOS Safari does not
// synthesize mousedown reliably from touch).
useEffect(() => {
if (!listOpen) return;
const onMouseDown = (e: MouseEvent) => {
const onPointerDown = (e: MouseEvent | TouchEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setListOpen(false);
}
};
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown, { passive: true });
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [listOpen]);
// Listen for AudioContext resume events so the supportsSinkId check
@@ -120,16 +120,23 @@ export function VideoSection() {
if (videoEl) videoEl.srcObject = null;
};
// Close the dropdown when the user clicks outside it.
// Close the dropdown when the user clicks outside it. Listens for both
// mousedown and touchstart so the popover dismisses with a single tap on
// touch devices (iOS Safari does not synthesize mousedown reliably from
// touch).
useEffect(() => {
if (!listOpen) return;
const onMouseDown = (e: MouseEvent) => {
const onPointerDown = (e: MouseEvent | TouchEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setListOpen(false);
}
};
document.addEventListener('mousedown', onMouseDown);
return () => document.removeEventListener('mousedown', onMouseDown);
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown, { passive: true });
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [listOpen]);
// Tab-visibility cleanup: release the camera light when the tab is hidden.
@@ -508,6 +515,7 @@ export function VideoSection() {
ref={previewVideoRef}
muted
playsInline
autoPlay
className={`w-full h-full object-cover ${isDormant ? 'invisible' : ''}`}
style={{ transform: 'scaleX(-1)' }}
/>
@@ -528,7 +536,11 @@ export function VideoSection() {
<button
type="button"
onClick={stopPreviewFromUser}
className="absolute top-2 right-2 text-[11px] px-2 py-1 rounded-md bg-black/60 hover:bg-black/75 text-white/90 transition-colors opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
// Mobile: always visible (no hover state) and 44 px tap target
// per iOS HIG. Desktop: original hover-reveal compact pill.
className="absolute top-2 right-2 rounded-md bg-black/60 hover:bg-black/75 text-white/90 transition-colors focus-visible:opacity-100
min-h-[44px] min-w-[44px] px-3 py-2 text-xs flex items-center justify-center
md:min-h-0 md:min-w-0 md:text-[11px] md:px-2 md:py-1 md:opacity-0 md:group-hover:opacity-100"
>
Stop preview
</button>