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
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Drag-to-close gesture hook for bottom sheets.
*
* Returns the props you spread onto the *drag-handle area* (typically the
* sheet's top section: visible handle pill + header), plus the live
* `dragOffset` to apply as a `translateY` on the sheet container.
*
* Behaviour
* ─────────
* - The user must start the touch on the drag-handle/header area (the spread
* props live on a single element). Touches on the body/scrollable region are
* never captured, so internal scrolling is unaffected.
* - While the finger moves down, the sheet follows 1:1 via `translateY`. Up-
* ward drag is clamped to 0 (rubber-band kept simple — we don't want any
* visual feedback that suggests "drag up to expand", which we don't support).
* - On release:
* - If the drag distance exceeds the close threshold *or* the user is
* flicking down faster than the velocity threshold, we trigger the close
* animation: `translateY` glides from the current offset to off-screen
* (200 ms ease-out), THEN `onClose` fires. We never snap back to 0 first.
* - Otherwise we snap back to 0 with the same easing.
* - Lateral scroll is not blocked; vertical drag past a small dead-zone calls
* `preventDefault` so iOS Safari doesn't simultaneously scroll the page or
* trigger pull-to-refresh on Chrome Android.
*
* Open-keyframe coexistence
* ─────────────────────────
* Consumers typically apply an `animate-slide-up-sheet` CSS keyframe on mount
* (a 200 ms `translateY(100%) → translateY(0)` ramp). That keyframe must be
* suppressed any time we're driving `transform` ourselves via inline style —
* otherwise React re-render cycles will re-add the class and the keyframe
* will fight (or replace) the inline transform. The hook exposes
* `hasInteracted` for this purpose: it flips `true` on the first touchstart
* and stays `true` for the lifetime of the consumer mount, so consumers can
* write `${hasInteracted ? '' : 'animate-slide-up-sheet'}`. This gates the
* keyframe both during drag (isDragging), during snap-back (isDragging=false
* + dragOffset transitioning back to 0), and during the close-out animation
* (isClosing=true + dragOffset = viewport height).
*
* The hook is otherwise animation-agnostic. The close-out transition uses
* `transform Xms ease-out` applied via inline style, where X is
* `closeAnimationMs` (defaults to 200, matching `slide-up-sheet`'s timing).
*
* Tap-on-handle is treated as a no-op: a touch that ends within the dead-zone
* without crossing the velocity/threshold gates simply snaps back, which
* matches iOS native sheet behaviour (a tap on the grabber doesn't dismiss).
*/
interface DragToCloseOptions {
onClose: () => void;
/**
* Threshold in pixels below the resting position above which the sheet
* commits to closing on release. If the consumer doesn't pass a height, we
* fall back to a fixed 100 px threshold.
*/
closeThreshold?: number;
/**
* Velocity in px/ms; releasing faster than this in the down direction
* commits to a close regardless of distance.
*/
velocityThreshold?: number;
/**
* Optional override for the closing animation duration (ms). Default 200
* matches `tailwind.config.js`'s `slide-up-sheet` keyframe.
*/
closeAnimationMs?: number;
/** Disable the gesture without unmounting the consumer. */
enabled?: boolean;
}
interface DragToCloseResult {
/** Inline style to spread onto the sheet container. */
sheetStyle: React.CSSProperties;
/** Spread onto the drag-handle / header area. */
handleProps: {
onTouchStart: (e: React.TouchEvent) => void;
};
/** True while the user is actively dragging. */
isDragging: boolean;
/** True while the close-out animation is running (after threshold met). */
isClosing: boolean;
/**
* True once the user has touched the drag handle at least once. Stays true
* for the rest of the component's lifetime. Consumers use this to suppress
* their open-animation keyframe so it doesn't fight the inline transform on
* snap-back / close-out.
*/
hasInteracted: boolean;
}
export function useDragToClose({
onClose,
closeThreshold = 100,
velocityThreshold = 0.5,
closeAnimationMs = 200,
enabled = true,
}: DragToCloseOptions): DragToCloseResult {
const [dragOffset, setDragOffset] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [hasInteracted, setHasInteracted] = useState(false);
const startYRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const lastYRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
const movedPastDeadzoneRef = useRef<boolean>(false);
// Latest values used inside document-level listeners. Refs avoid re-binding
// listeners every render.
const enabledRef = useRef(enabled);
const onCloseRef = useRef(onClose);
const closeThresholdRef = useRef(closeThreshold);
const velocityThresholdRef = useRef(velocityThreshold);
const closeAnimationMsRef = useRef(closeAnimationMs);
useEffect(() => { enabledRef.current = enabled; }, [enabled]);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
useEffect(() => { closeThresholdRef.current = closeThreshold; }, [closeThreshold]);
useEffect(() => { velocityThresholdRef.current = velocityThreshold; }, [velocityThreshold]);
useEffect(() => { closeAnimationMsRef.current = closeAnimationMs; }, [closeAnimationMs]);
// Document-level move/end handlers are installed only while a drag is in
// progress. Installing them on every render would interfere with passive
// listeners elsewhere; installing them on demand keeps the gesture inert
// when the sheet is idle.
useEffect(() => {
if (!isDragging) return;
const handleTouchMove = (e: TouchEvent) => {
if (!enabledRef.current || startYRef.current === null) return;
const touch = e.touches[0];
if (!touch) return;
const dy = touch.clientY - startYRef.current;
const now = performance.now();
lastYRef.current = touch.clientY;
lastTimeRef.current = now;
// Dead-zone: ignore the first ~6 px to avoid stealing taps and tiny
// scrolls. Once we cross it, we own the gesture.
if (!movedPastDeadzoneRef.current) {
if (Math.abs(dy) < 6) return;
movedPastDeadzoneRef.current = true;
}
// Block native scroll/pull-to-refresh while we drive the offset.
if (e.cancelable) e.preventDefault();
// Clamp to 0 on the upward side (no over-scroll).
const offset = Math.max(0, dy);
setDragOffset(offset);
};
const finalize = (commitClose: boolean, releaseDy: number) => {
if (commitClose) {
// Animate from the *current* offset to fully off-screen, then unmount.
// Critical: we must NOT reset to 0 first — that would visually bounce
// the sheet up before the close. We flip `isDragging` off (so the
// inline `transition` engages) and `isClosing` on (so consumers'
// `hasInteracted`-gated open-keyframe stays suppressed even after
// unmount), then push the offset to viewport height. The transition
// smoothly glides the sheet off the bottom; once `closeAnimationMs`
// elapses we fire `onClose` and the consumer unmounts us.
setIsDragging(false);
setIsClosing(true);
const startOffset = Math.max(0, releaseDy);
const exitDistance = window.innerHeight; // generous — sheet may be tall
// Apply the start offset on the same frame we flip transition on, so
// the browser has a stable "from" value before we animate to "to".
setDragOffset(startOffset);
requestAnimationFrame(() => {
setDragOffset(exitDistance);
});
window.setTimeout(() => {
onCloseRef.current();
}, closeAnimationMsRef.current);
} else {
// Snap back. `isDragging` flips to false → inline transition engages
// and the sheet glides from `dragOffset` back to 0.
setIsDragging(false);
setDragOffset(0);
}
};
const handleTouchEnd = () => {
if (!enabledRef.current || startYRef.current === null) {
startYRef.current = null;
setIsDragging(false);
setDragOffset(0);
return;
}
const totalDy = Math.max(0, lastYRef.current - startYRef.current);
const totalDt = Math.max(1, lastTimeRef.current - startTimeRef.current);
const velocity = totalDy / totalDt; // px/ms, positive = down
const overDistance = totalDy > closeThresholdRef.current;
const overVelocity = velocity > velocityThresholdRef.current && totalDy > 16;
const commitClose =
movedPastDeadzoneRef.current && (overDistance || overVelocity);
startYRef.current = null;
movedPastDeadzoneRef.current = false;
finalize(commitClose, totalDy);
};
const handleTouchCancel = () => {
startYRef.current = null;
movedPastDeadzoneRef.current = false;
// Same as snap-back — engage the transition by flipping isDragging off
// and letting dragOffset glide to 0.
setIsDragging(false);
setDragOffset(0);
};
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
document.addEventListener('touchcancel', handleTouchCancel);
return () => {
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
document.removeEventListener('touchcancel', handleTouchCancel);
};
}, [isDragging]);
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (!enabledRef.current) return;
if (e.touches.length !== 1) return;
const touch = e.touches[0];
if (!touch) return;
startYRef.current = touch.clientY;
lastYRef.current = touch.clientY;
startTimeRef.current = performance.now();
lastTimeRef.current = startTimeRef.current;
movedPastDeadzoneRef.current = false;
setIsDragging(true);
setHasInteracted(true);
// Reset closing state in case the previous close was cancelled mid-flight
// (defensive — a successfully closed sheet has unmounted, so this branch
// only matters if a future consumer keeps the hook alive across close).
setIsClosing(false);
}, []);
// Build the inline style for the sheet container. While dragging we want NO
// transition (offset must follow the finger 1:1). When releasing without
// committing OR while closing, we want a transform transition so the
// movement glides smoothly. Always emit a `transform` value once
// `hasInteracted` is true so the browser has a stable from-value when
// dragOffset transitions; before any interaction we leave it undefined so
// the consumer's open keyframe (CSS `animation`) drives the entry without
// an inline `transform: translateY(0)` overriding it.
const transformValue =
dragOffset > 0
? `translateY(${dragOffset}px)`
: hasInteracted
? 'translateY(0)'
: undefined;
const sheetStyle: React.CSSProperties = {
transform: transformValue,
transition: isDragging
? 'none'
: `transform ${closeAnimationMs}ms cubic-bezier(0.22, 1, 0.36, 1)`,
// While dragging or closing, ensure we sit on the GPU's compositor layer
// and don't re-layout — `transform` alone already does this, but explicit
// `willChange` prevents any flicker on lower-end devices.
willChange: isDragging || isClosing ? 'transform' : undefined,
};
return {
sheetStyle,
handleProps: { onTouchStart },
isDragging,
isClosing,
hasInteracted,
};
}
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
/**
* Live geometry of `window.visualViewport`, plus a derived `inset` string
* that floating overlays (e.g. the chat composer) can paste into a `bottom`
* style to sit just above the iOS / Android soft keyboard when one is open,
* or above the system home indicator when one is not.
*
* Why this hook exists
* --------------------
* On iOS Safari (and PWA), `env(safe-area-inset-bottom)` is defined relative
* to the **layout** viewport, not the **visual** viewport. When the soft
* keyboard slides up, the layout viewport stays the same height and the home-
* indicator inset still reports ~34 px — so a composer pinned to
* `bottom: env(safe-area-inset-bottom) + 6px` ends up `~40px` above the
* layout-bottom, which on iPhone 14 Pro is `300+ px` above the keyboard top.
*
* `window.visualViewport` reports the live size of the visible region. When
* the keyboard is open, `visualViewport.height` shrinks and
* `visualViewport.offsetTop` may become non-zero. The bottom of the visual
* viewport (in layout-viewport coordinates) is therefore
* `visualViewport.offsetTop + visualViewport.height`. The distance between
* that line and the layout-viewport bottom is the keyboard occlusion:
* keyboardOcclusion = window.innerHeight - (offsetTop + height)
*
* When the keyboard is closed, that value is ~0 and we fall back to the
* standard `safe-area-inset-bottom` so the overlay sits above the home
* indicator. When the keyboard is open, we use the keyboard occlusion
* directly — `safe-area-inset-bottom` no longer applies because the home
* indicator is occluded by the keyboard.
*
* iOS PWA standalone caveats
* --------------------------
* In iOS Safari standalone PWA mode, `visualViewport.resize` events are
* known to fire late, fire only once after the keyboard finishes animating,
* or in some iOS versions not fire at all for the keyboard transition. To
* cover those cases we additionally:
* 1. Listen to `focusin` / `focusout` on `window` and re-measure (a focus
* change on a text input is a strong signal that the keyboard is about
* to open / close).
* 2. Poll `visualViewport` for ~600ms after a focus change so we catch the
* shrunk height even when no `resize` event ever lands.
* 3. Listen to `vv.scroll` events too — on some iOS builds the keyboard
* transition fires `scroll` (offsetTop change) without `resize`.
*
* Consumers
* ---------
* - `MobileShell.tsx` reads `{ height, keyboardOpen }` and uses `height` as
* the container's CSS height when the keyboard is open. This is the
* primary mechanism for the composer to sit flush above the keyboard:
* the container shrinks to the visible region, so a `position: absolute;
* bottom: 0` child naturally lands on the keyboard's top edge regardless
* of how reliably the `inset` value tracks the keyboard.
* - `MessageInput.tsx` reads `{ value, keyboardOpen }` and uses them only
* on desktop fallback paths and for the breathing-room toggle (above the
* home indicator vs flush with the keyboard).
*/
export interface VisualViewportInset {
/** CSS string for `bottom`: either `env(safe-area-inset-bottom)` or `<n>px`. */
value: string;
/** True if the soft keyboard is occluding the bottom of the layout viewport. */
keyboardOpen: boolean;
/**
* Live `visualViewport.height` in pixels, or `null` if `visualViewport` is
* unavailable. Consumers that want to size a container to the visible
* region (e.g. MobileShell when the keyboard is open) read this directly.
*/
height: number | null;
/**
* Live `visualViewport.offsetTop` in pixels (0 when no scroll occlusion at
* the top of the visible region), or `null` if `visualViewport` is
* unavailable.
*/
offsetTop: number | null;
}
const FALLBACK: VisualViewportInset = {
value: 'env(safe-area-inset-bottom)',
keyboardOpen: false,
height: null,
offsetTop: null,
};
export function useVisualViewportInset(): VisualViewportInset {
const [inset, setInset] = useState<VisualViewportInset>(FALLBACK);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
let raf = 0;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollDeadline = 0;
const measure = () => {
// Distance from the bottom of the layout viewport (window.innerHeight)
// to the bottom of the visual viewport (offsetTop + height). On iOS
// when the keyboard is up, this equals the keyboard's height.
const occlusion = window.innerHeight - (vv.offsetTop + vv.height);
// Sub-pixel noise on iOS — anything under 1 px we treat as "no
// keyboard" so we don't flap between safe-area and a 0.4 px offset.
const next: VisualViewportInset =
occlusion > 1
? {
value: `${Math.round(occlusion)}px`,
keyboardOpen: true,
height: vv.height,
offsetTop: vv.offsetTop,
}
: {
value: 'env(safe-area-inset-bottom)',
keyboardOpen: false,
height: vv.height,
offsetTop: vv.offsetTop,
};
// Functional update + shallow compare so identical re-measurements
// don't churn React state every animation frame during keyboard
// transitions.
setInset((prev) =>
prev.value === next.value &&
prev.keyboardOpen === next.keyboardOpen &&
prev.height === next.height &&
prev.offsetTop === next.offsetTop
? prev
: next,
);
};
const update = () => {
// Schedule a single rAF — `resize`/`scroll` on visualViewport can fire
// many times per frame on iOS during keyboard transitions; coalescing
// avoids redundant React state updates.
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = 0;
measure();
});
};
/**
* iOS PWA fallback: poll for ~600 ms after a focus change. iOS Safari
* (especially in standalone PWA mode) often fails to dispatch a
* `visualViewport.resize` event when the soft keyboard opens — but the
* `vv.height` value itself does update once the keyboard finishes
* animating. Polling at ~16 ms intervals from `focusin` until the
* deadline ensures we observe the shrunk height even when no event
* fires. The interval clears as soon as we observe a steady state for
* two consecutive frames.
*/
let lastPolledHeight = vv.height;
let stableFrames = 0;
const startPolling = (durationMs: number) => {
pollDeadline = performance.now() + durationMs;
lastPolledHeight = vv.height;
stableFrames = 0;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
measure();
if (vv.height === lastPolledHeight) {
stableFrames += 1;
} else {
lastPolledHeight = vv.height;
stableFrames = 0;
}
if (stableFrames >= 3 || performance.now() > pollDeadline) {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
// One last measurement after we stop, in case the value just
// settled this tick.
measure();
}
}, 32);
};
const onFocusChange = (e: FocusEvent) => {
// Only react to focus changes on text-entry elements — focusing a
// <button> never opens the soft keyboard, so polling for it would
// waste cycles.
const t = e.target as Element | null;
if (!t) return;
const tag = t.tagName;
const editable =
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
(t as HTMLElement).isContentEditable === true;
if (!editable) return;
// Immediate measure + a polling window for laggy iOS PWA event flows.
update();
startPolling(600);
};
measure();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
window.addEventListener('focusin', onFocusChange, true);
window.addEventListener('focusout', onFocusChange, true);
return () => {
if (raf) cancelAnimationFrame(raf);
if (pollTimer) clearInterval(pollTimer);
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
window.removeEventListener('focusin', onFocusChange, true);
window.removeEventListener('focusout', onFocusChange, true);
};
}, []);
return inset;
}