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