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
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { EmojiPicker } from './EmojiPicker';
import { GifPicker } from './GifPicker';
import { useUIStore } from '../../stores/uiStore';
import { useDragToClose } from '../../hooks/useDragToClose';
export type InputPopoverTab = 'emoji' | 'gif';
@@ -170,6 +171,14 @@ function MobileSheet({
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
// Drag-down-to-close. Only the handle + tab-bar area receives the touch;
// the picker grids manage their own scrolling and must not be hijacked.
// `hasInteracted` flips true on the first touchstart and stays true — we
// use it to suppress the `animate-slide-up-sheet` keyframe from re-running
// during snap-back / close-out, which would otherwise fight the inline
// transform the hook is animating.
const { sheetStyle, handleProps, hasInteracted } = useDragToClose({ onClose });
return createPortal(
<>
{/* Backdrop — single tap (mousedown OR touchstart) closes */}
@@ -180,7 +189,9 @@ function MobileSheet({
/>
{/* Sheet */}
<div
className="fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet flex flex-col"
className={`fixed left-0 right-0 z-[301] rounded-t-2xl glass-modal flex flex-col ${
hasInteracted ? '' : 'animate-slide-up-sheet'
}`}
style={{
// Sit at the bottom of the visible viewport. On iOS 16.4+ the
// `keyboard-inset-height` env var lifts us above the soft keyboard;
@@ -190,15 +201,21 @@ function MobileSheet({
bottom: 'env(keyboard-inset-height, 0px)',
paddingBottom: 'env(safe-area-inset-bottom)',
maxHeight: 'min(60dvh, 60vh)',
...sheetStyle,
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
{/* Drag handle */}
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
{/* Tab bar (only when multiple tabs available) */}
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
{/* Drag handle + tab bar — both belong to the "header" drag area.
Spreading `handleProps` here means the user can grab anywhere in
this top region (handle pill, padding around it, tab buttons'
interstitial space) to dismiss; tab buttons themselves still
receive their own clicks because clicks aren't blocked, only
vertical drag past the dead-zone is. */}
<div {...handleProps} className="shrink-0 touch-none">
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">