feat(mobile): chat polish — floating composer + iOS keyboard handling + bottom-sheet drag-close + file-chip overflow

Multi-pass mobile chat polish landed across this session.

- MessageInput is now a floating glass-bubble (`position: absolute`) on both desktop and mobile — last messages scroll *behind* the translucent bubble. MobileChatScreen wraps MessageList + MessageInput in a `relative` parent so absolute positioning resolves. Removed the prior in-flow mobile branch that clipped message-list bottom against an invisible barrier.
- iOS PWA keyboard handling: new `useVisualViewportInset` hook subscribes to `visualViewport.resize/scroll` AND polls `vv.height` for ~600ms after focusin (iOS PWA standalone often fails to dispatch resize for keyboard transitions). MobileShell sizes its container to `vv.height` when keyboard is open — `bottom: 0` on the composer naturally lands flush with the keyboard top, regardless of how reliably resize events fire. Composer uses 6px gap above home indicator (keyboard closed) and 0px gap above keyboard (keyboard open). Added `interactive-widget=resizes-content` viewport meta as the cleaner native equivalent for Chrome/Android.
- MessageList bottom padding is dynamic via `--composer-clearance` CSS variable. MessageInput writes `composerHeight + bottomOffset + 12px` to its parent via ResizeObserver — re-fires on textarea autosize, reply banner, attachment tile growth, parent resize. Last message always has 12px breathing room above the bubble regardless of composer state.
- AttachmentRenderer generic file chip: `max-w-full sm:max-w-[400px]` on outer + `min-w-0` + `flex-shrink-0` on icon + `flex-1` on text + `flex-wrap` on badge row. Long filenames now ellipsize cleanly on narrow viewports instead of pushing the chip off-screen.
- New `useDragToClose` hook: shared bottom-sheet drag-down-to-dismiss gesture. Spread on handle/header only (body scrolling unaffected). 6px deadzone, 100px or 0.5px/ms velocity threshold, 200ms `cubic-bezier(0.22, 1, 0.36, 1)` close-out animation, rAF-staged transform for a stable from-value. `hasInteracted` latch prevents the open keyframe from re-firing mid-close (the bounce-up-then-vanish bug). Wired into InputPopover (emoji/GIF), MobileVoiceJoinSheet, MobileFolderSheet.

Specs: docs/systems/mobile-ui.md (Floating Composer + Drag-to-Close sections), docs/systems/message-list.md (--composer-clearance), docs/systems/design-system.md (glass-bubble row references).
This commit is contained in:
Jannis Braun
2026-05-08 10:23:32 +02:00
parent cdb4b5f41f
commit 6fb38d391c
14 changed files with 915 additions and 43 deletions
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useDragToClose } from '../../hooks/useDragToClose';
import { VoiceUserRow } from './VoiceUserRow';
/**
@@ -319,6 +320,19 @@ export function MobileVoiceJoinSheet({
onClose();
}, [onClose]);
// Drag-down-to-close. The handle + header region owns the gesture; touches
// on the camera tile / user list / action bar are unaffected so internal
// scrolling and button taps still work.
// `hasInteracted` stays true after the first touchstart so we never re-
// apply the open-animation classes (`translate-y-full → translate-y-0`)
// mid-drag or mid-close — those classes would otherwise fight the inline
// transform the hook drives.
const {
sheetStyle: dragStyle,
handleProps: dragHandleProps,
hasInteracted,
} = useDragToClose({ onClose });
// Explicit user gesture: open getUserMedia for the selected camera.
const startPreviewFromUser = useCallback(async () => {
setPreviewError(null);
@@ -384,19 +398,32 @@ export function MobileVoiceJoinSheet({
onClick={handleBackdropClick}
/>
{/* Sheet container */}
{/* Sheet container.
Open animation: when `visible` is false we sit at translateY(100%);
flipping it true triggers the existing 300 ms slide-in.
Drag-to-close: while the user drags, `dragStyle.transform` overrides
the open transform with the live finger offset. Releasing past the
threshold extends the offset to off-screen and calls `onClose`. */}
<div
className={`glass-bubble fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl transition-transform duration-300 ease-out ${
visible ? 'translate-y-0' : 'translate-y-full'
className={`glass-bubble fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl ${
hasInteracted
? ''
: `transition-transform duration-300 ease-out ${visible ? 'translate-y-0' : 'translate-y-full'}`
}`}
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
style={{
paddingBottom: 'env(safe-area-inset-bottom)',
...dragStyle,
}}
>
{/* Drag handle */}
<div className="w-10 h-1 rounded-full bg-white/20 mx-auto mt-3 mb-4" />
{/* Drag handle + header — both belong to the drag-to-close region.
The user can grab the visible pill OR the title row to drag down. */}
<div {...dragHandleProps} className="touch-none">
<div className="w-10 h-1 rounded-full bg-white/20 mx-auto mt-3 mb-4" />
{/* Header */}
<div className="flex items-center justify-between px-5 mb-2">
<h2 className="text-base font-bold text-txt-primary truncate">{channelName}</h2>
{/* Header */}
<div className="flex items-center justify-between px-5 mb-2">
<h2 className="text-base font-bold text-txt-primary truncate">{channelName}</h2>
</div>
</div>
{/* User count */}