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