From 6fb38d391cccfe873ce0fad5236341b061671cf9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 8 May 2026 10:23:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20chat=20polish=20=E2=80=94=20flo?= =?UTF-8?q?ating=20composer=20+=20iOS=20keyboard=20handling=20+=20bottom-s?= =?UTF-8?q?heet=20drag-close=20+=20file-chip=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/systems/design-system.md | 2 +- docs/systems/message-list.md | 6 + docs/systems/mobile-ui.md | 143 ++++++++- packages/web/index.html | 2 +- .../components/chat/AttachmentRenderer.tsx | 18 +- .../web/src/components/chat/InputPopover.tsx | 29 +- .../web/src/components/chat/MessageInput.tsx | 160 +++++++++- .../web/src/components/chat/MessageList.tsx | 6 +- .../components/layout/MobileChatScreen.tsx | 20 +- .../components/layout/MobileFolderSheet.tsx | 24 +- .../web/src/components/layout/MobileShell.tsx | 14 +- .../components/voice/MobileVoiceJoinSheet.tsx | 45 ++- packages/web/src/hooks/useDragToClose.ts | 278 ++++++++++++++++++ .../web/src/hooks/useVisualViewportInset.ts | 211 +++++++++++++ 14 files changed, 915 insertions(+), 43 deletions(-) create mode 100644 packages/web/src/hooks/useDragToClose.ts create mode 100644 packages/web/src/hooks/useVisualViewportInset.ts diff --git a/docs/systems/design-system.md b/docs/systems/design-system.md index 6518dc73..04e49066 100644 --- a/docs/systems/design-system.md +++ b/docs/systems/design-system.md @@ -53,7 +53,7 @@ Font: DM Sans (primary) with system fallbacks |------|-------|-------------| | Structural | `bg-surface-*` | Permanent layout (sidebars, chat, member list) | | Strip | `.glass-strip` | Persistent edge chrome (space sidebar) | -| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, input pill) | +| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, **chat composer**, voice mini-bar). Chat composer is a floating bubble on **both** desktop and mobile (`position: absolute`, sits above the message-list scroll area; messages scroll behind it). On mobile, its `bottom` value is driven by `useVisualViewportInset()` so it lifts above the iOS soft keyboard when one is open. See `docs/systems/mobile-ui.md` "Floating Composer" for the full pattern. | | Popover | `.glass` | Small floating surfaces (context menus, popovers, tooltips) | | Tray dropdown | `.glass` (popover) | `TransferIndicator` global panel — anchored under the channel-header icon. | | Modal | `.glass-modal` | Large center-screen dialogs | diff --git a/docs/systems/message-list.md b/docs/systems/message-list.md index 6b420cb2..b0842877 100644 --- a/docs/systems/message-list.md +++ b/docs/systems/message-list.md @@ -11,6 +11,12 @@ The chat message list (`packages/web/src/components/chat/MessageList.tsx`) is re | `packages/web/src/components/chat/embeds/*.tsx` | Embed renderers — must obey the dimension reservation contract below. | | `packages/web/src/components/chat/AttachmentRenderer.tsx` | Reference for the dimension reservation pattern (`AttachmentRenderer.tsx:81-100`). | +## Bottom clearance — `--composer-clearance` + +The MessageList content's `paddingBottom` is dynamic — it reads the CSS variable `var(--composer-clearance, 80px)` written to the chat region's wrapper element by `MessageInput` via a `ResizeObserver`. The variable's value is `composer.height + composer.bottom-offset + 12 px` so the last message always lands 12 px above the composer's top edge regardless of (a) composer height (reply banner, staged-attachment tile row, multi-line autosize), (b) the composer's own `bottom` style (desktop 12 px / mobile-keyboard-closed `env(safe-area-inset-bottom) + 6` / mobile-keyboard-open 0). + +The 80 px fallback is sized for the desktop case during the brief mount window before the first ResizeObserver tick — it matches the previous static `pb-20`. See `docs/systems/mobile-ui.md` "Floating Composer" for the full rationale; the short version is that any static value is wrong on iPhone (composer height + safe-area + 6 already exceeds 80 px) and wrong when the composer grows (replies, attachments). + ## ContainerRef invariant `containerRef.current` (and `contentRef.current`) MUST be non-null for the entire lifetime of any chat view where messages may arrive or scroll-affecting effects can run. Every effect in this file (initial snap A, ResizeObserver B, capture-phase load handler C, scrollend final-pin D, jump-to-message) reads these refs and bails on a null guard. Each of those effects is keyed on `[messages.length, channelId]` or `[hasMessages, channelId]`, and the inbound transition (`0 → N` / `false → true`) is the *only* re-fire signal during a channel-load lifecycle. If a ref is null at the moment that signal fires, the effect bails — and no later dep change will retry it, leaving the channel permanently broken (initial scroll never lands at bottom, ResizeObserver never observes, scrollend never registers, saved-anchor restore never happens). diff --git a/docs/systems/mobile-ui.md b/docs/systems/mobile-ui.md index 0c496d8e..e5682dc5 100644 --- a/docs/systems/mobile-ui.md +++ b/docs/systems/mobile-ui.md @@ -17,6 +17,8 @@ Source files: - `packages/web/src/components/layout/MobileVoiceMiniBar.tsx` — Persistent mini-bar overlay during voice calls - `packages/web/src/components/layout/MobileFolderSheet.tsx` — Bottom sheet for space folder contents, rename, color, ungroup - `packages/web/src/hooks/useSwipeGesture.ts` — Edge swipe-back touch gesture hook +- `packages/web/src/hooks/useDragToClose.ts` — Bottom-sheet drag-down-to-dismiss gesture hook (shared by `InputPopover.MobileSheet`, `MobileVoiceJoinSheet`, `MobileFolderSheet`) +- `packages/web/src/hooks/useVisualViewportInset.ts` — Returns the bottom inset that floating overlays must use to sit above the iOS soft keyboard (when open) or above the home-indicator safe area (when closed). Used by `MessageInput` for the floating composer-bubble's `bottom` value. - `packages/web/src/stores/uiStore.ts` — Mobile navigation state (mobileScreen, mobileStack, push/pop actions) Cross-references: @@ -580,8 +582,147 @@ Several mobile components respect the iOS safe area inset: | MobileVoiceFullScreen control bar | `marginBottom: calc(0.5rem + env(safe-area-inset-bottom))` | | MobileFolderSheet | `paddingBottom: env(safe-area-inset-bottom)` | | MobileSpacesScreen add sheet | `paddingBottom: env(safe-area-inset-bottom)` | +| MessageInput | `bottom: calc(env(safe-area-inset-bottom) + 6px)` (keyboard closed) / `0px` (keyboard open) — see "Floating Composer" below | -The root MobileShell uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome. +The root `MobileShell` normally uses `height: 100dvh` (dynamic viewport height) to account for mobile browser chrome. **When the iOS soft keyboard is open** (detected via `useVisualViewportInset().keyboardOpen`), the shell switches to `height: ${visualViewport.height}px` so the visible region of the shell is exactly the area above the keyboard. This is the load-bearing mechanism for the floating composer landing flush against the keyboard top — see "Floating Composer" below. + +--- + +## Floating Composer (Chat MessageInput) + +The chat composer (`MessageInput.tsx`) is a `glass-bubble` floating overlay on **both** desktop and mobile — there is no flow-positioned mobile branch. The pattern is shared so feature parity is automatic. + +### Layout model + +``` +┌──────────────────────────────────────────────┐ +│ MobileChatScreen / MainContent │ ← `relative flex flex-col` +│ ┌────────────────────────────────────────┐ │ +│ │ │ │ ← fills the chat region +│ │ (scrolls; content's `paddingBottom` │ │ +│ │ = `var(--composer-clearance, 80px)` │ │ +│ │ so the last message always clears │ │ +│ │ the bubble with a 12px breathing gap)│ │ +│ └────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────┐ │ +│ │ │ │ ← `position: absolute` +│ │ glass-bubble, translucent │ │ `left-2 right-2 z-[110]` +│ │ bottom = 0 (kbd open) / safe+6 (kbd │ │ (mobile) / `md:left-3 md:right-3 md:bottom-3` (desktop) +│ │ closed). Writes --composer-clearance │ │ +│ │ on its parent via ResizeObserver. │ │ +│ └────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +The MessageList scroll region fills the entire chat area. The composer is `position: absolute` and overlays the bottom; messages scroll *behind* the translucent bubble. **Containers must declare `position: relative`** for the composer's absolute positioning to resolve correctly — `MainContent` does this on its outermost flex column; `MobileChatScreen` does this on the inner messages-and-composer wrapper. + +### Last-message clearance — the `--composer-clearance` CSS variable + +The MessageList content's `paddingBottom` is **dynamic**, driven by a CSS variable named `--composer-clearance` written to the chat region's wrapper element by `MessageInput` via a `ResizeObserver`: + +``` +--composer-clearance = composer.height + composer.bottom-offset + 12 px +``` + +Where `bottom-offset` is the gap between the wrapper's bottom edge and the composer's bottom edge (i.e. the resolved value of the composer's `bottom` style — `12 px` on desktop, `env(safe-area-inset-bottom) + 6` ≈ `40 px` on iPhone with keyboard closed, `0` on mobile with keyboard open). The `+12 px` constant is the desired breathing-room gap between the last message's bottom edge and the composer's top edge. + +`MessageList` reads `var(--composer-clearance, 80px)` as `paddingBottom`. The `80px` fallback covers the brief mount window before the first measurement, plus any future surface that mounts a `MessageList` without a sibling `MessageInput`. + +**Why dynamic?** The previous static `pb-20` (80 px) was sized for the desktop case (composer ≈ 50 px tall + 12 px bottom = 62 px, leaving 18 px of gap). On iPhone with the keyboard closed, the composer's bottom-offset is `env(safe-area-inset-bottom) + 6` ≈ 40 px, so `composer-height + bottom-offset` ≈ `44 + 40` = `84 px` — already exceeding the 80 px `pb-20`, with **negative** breathing room. The composer also grows when the user replies to a message (banner adds 36 px) or stages attachments (tile row adds 184 px), so any static value is wrong for some configurations. The ResizeObserver-driven CSS variable is the only correct model. + +The variable is scoped to the chat region's wrapper rather than `:root` so future multi-pane layouts (e.g. side-by-side DM list + chat, voice chat side-panel) don't cross-talk; a wrapper-scoped variable inherits naturally to its `MessageList` descendant. + +### `useVisualViewportInset()` — keyboard-aware geometry + +iOS Safari's `env(safe-area-inset-bottom)` is defined relative to the **layout** viewport (full screen), not the **visual** viewport (the visible region above the soft keyboard). When the iOS soft keyboard slides up, the layout viewport stays the same height and `safe-area-inset-bottom` still reports ~34 px (the home-indicator inset). A composer pinned to `bottom: env(safe-area-inset-bottom) + 6 px` therefore ends up `~40 px` above the layout-bottom, which on iPhone 14 Pro is `300+ px` above the keyboard — there is a huge empty gap between the composer and the keyboard top. + +The hook subscribes to `window.visualViewport.resize` / `scroll` and computes: + +``` +keyboardOcclusion = window.innerHeight - (visualViewport.offsetTop + visualViewport.height) +``` + +It returns `{ value, keyboardOpen, height, offsetTop }`: +- `value` — `'px'` when the keyboard is open (the occlusion), or the literal `'env(safe-area-inset-bottom)'` string when it is not. Provided for legacy / fallback use. +- `keyboardOpen` — `true` when `keyboardOcclusion > 1`. +- `height` — live `visualViewport.height` in pixels (or `null` if `visualViewport` is unavailable). +- `offsetTop` — live `visualViewport.offsetTop` in pixels. + +#### iOS PWA standalone — the load-bearing mechanism + +`MobileShell` consumes `{ keyboardOpen, height }` and sets its own `style.height` to `${vv.height}px` whenever the keyboard is open. The chat region's `bottom` edge is therefore exactly the keyboard's top edge, and `` lands flush. **This is the primary mechanism, not the inset arithmetic** — sizing the container is far more robust than arithmetic on a `bottom` value, because the math depends on `vv.resize` events firing reliably (which they do not in iOS standalone PWA on several iOS versions). The composer's `bottom` is a simple binary toggle: `0` when keyboard open, `env(safe-area-inset-bottom) + 6 px` when closed. + +To cover the case where `vv.resize` fails to fire on iOS PWA (a long-standing standalone-mode bug), the hook **also** listens to `focusin` / `focusout` on `window` for any text-entry element and **polls** `vv.height` at 32 ms intervals for up to 600 ms after the focus change. Polling exits early once the height is stable for two consecutive ticks. This catches the case where iOS silently updates `vv.height` without dispatching a `resize` event — the polling just re-reads the value and re-derives `keyboardOpen`, which then triggers the shell-height update. + +When the keyboard is closed, `MobileShell` reverts to `height: 100dvh` so the shell again extends through the home-indicator safe area, and the composer reverts to `bottom: env(safe-area-inset-bottom) + 6 px` so it sits 6 px above the home indicator. + +#### Viewport meta hint + +`packages/web/index.html`'s viewport `` includes `interactive-widget=resizes-content`. Chrome (Android) honors this by resizing the layout viewport when the soft keyboard opens, which is the cleaner native equivalent of what `MobileShell` does manually. Safari iOS does not honor it, but it's harmless there. + +### MessageInput's mobile vs. desktop class split + +The component declares one `composerClass` shared by both modes. Differences: + +- Mobile inline `style={{ bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }}` — applied only when `useUIStore.isMobile === true`. The hook is safe to call on desktop (no-ops), but the `style` is only emitted on mobile so desktop's CSS-driven `md:bottom-3` (12 px) constant is unaffected by the inline override. +- Tailwind: `absolute left-2 right-2 z-[110] glass-bubble rounded-[14px] md:left-3 md:right-3 md:bottom-3`. The `left-2/right-2` 8 px inset is mobile; `md:left-3/right-3/bottom-3` overrides to 12 px on desktop. `bottom` is intentionally NOT in the Tailwind class on mobile — the inline `style.bottom` provides the dynamic value. + +`TypingIndicator` is rendered inside `MessageInput` (anchored `absolute bottom-full` to the bubble) so it appears just above the composer. Mobile chat screens must NOT render an additional `TypingIndicator` themselves. + +#### Composer-element ref shape + +`MessageInput` tracks the live composer DOM element via a callback ref that fans out to (a) the existing imperative `popoverAnchorRef` (consumed by `InputPopover` and the mention popover for anchor positioning) AND (b) a state-backed `composerEl` slot that drives the `--composer-clearance` ResizeObserver effect. The state-backed slot is required because the component renders different JSX when `canSendMessages` is false (the no-permission early-return path) vs. true (the full composer): a plain `useEffect` keyed only on stable deps would not re-fire when the ref attaches as the JSX flips, leaving the CSS variable unset until the next dep change. Channel permissions arrive asynchronously, so the initial mount renders the no-permission JSX first and re-renders the full composer once permissions resolve — the callback ref's `setComposerEl` call re-fires the effect at that moment. + +--- + +## Bottom-Sheet Drag-to-Close (`useDragToClose`) + +File: `packages/web/src/hooks/useDragToClose.ts` + +Three hand-rolled bottom sheets share this gesture hook so each surface gets identical iOS-native-feeling dismissal without depending on a third-party gesture library: + +| Sheet | File | Drag-handle area | +|---|---|---| +| Emoji / GIF picker | `packages/web/src/components/chat/InputPopover.tsx` (`MobileSheet`) | Visible pill + tab bar | +| Voice-channel join sheet | `packages/web/src/components/voice/MobileVoiceJoinSheet.tsx` | Visible pill + title row | +| Space-folder sheet | `packages/web/src/components/layout/MobileFolderSheet.tsx` | Visible pill + folder header row | + +### Hook contract + +```ts +const { sheetStyle, handleProps, isDragging, isClosing, hasInteracted } = useDragToClose({ + onClose, // required + closeThreshold = 100, // px below resting → commits to close on release + velocityThreshold = 0.5, // px/ms downward → commits to close on release + closeAnimationMs = 200, // close-out transition duration (matches the open keyframe) + enabled = true, +}); +``` + +- `sheetStyle` — spread onto the sheet container's inline `style`. While dragging, applies `transform: translateY(px)` with `transition: none` so the sheet follows the finger 1:1. On release, `transition: transform ms cubic-bezier(0.22, 1, 0.36, 1)` engages and the inline `transform` glides smoothly. If the close gate is met, `dragOffset` is **animated from the current offset directly to viewport height** (no intermediate snap-back to 0), and `onClose` fires after the animation completes. +- `handleProps` — `{ onTouchStart }`. Spread onto the **drag-handle area** (the visible pill + the sheet's header row, never the scrollable content). Scrollable regions and tappable buttons inside the body are unaffected because the document-level `touchmove` / `touchend` listeners are only installed once a drag is in flight. +- `isDragging` — true between `touchstart` and `touchend`. +- `isClosing` — true during the close-out animation phase (after the threshold/velocity gate fires, until `onClose` fires). +- `hasInteracted` — flips `true` on the first touchstart and stays `true` for the lifetime of the consumer mount. **Consumers MUST gate their open-animation classes on `!hasInteracted`** (e.g. `${hasInteracted ? '' : 'animate-slide-up-sheet'}`) so the open keyframe doesn't re-run during snap-back / close-out and fight the inline `transform` driven by `sheetStyle`. Using `isDragging` alone is insufficient — once `isDragging` flips back to false (release), the keyframe re-applies on the next render and the `translateY(100%) → translateY(0)` ramp visually overrides the glide. + +### Gesture behaviour + +| Phase | Behaviour | +|---|---| +| Touch start on handle | Captures finger Y + `performance.now()`. No visible change yet. `hasInteracted` flips to `true`. | +| Move dy < 6 px | Dead-zone — ignored, native scrolling/pull-to-refresh still possible. | +| Move dy ≥ 6 px | Commits to drag. `e.preventDefault()` blocks scroll/pull-to-refresh. Sheet follows finger. | +| Release dy < 100 px and v < 0.5 px/ms | Snap back: `dragOffset → 0` via inline `transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1)`. Sheet stays open. | +| Release dy ≥ 100 px **or** v > 0.5 px/ms (and dy > 16 px) | Snap close: `isClosing` flips on, transition engages, `dragOffset` ramps from current value to `window.innerHeight` over `closeAnimationMs`, then `onClose()` fires. **No intermediate snap to 0.** | +| Touch cancel | Snap back to 0 with the same transition (consistent with release-without-commit). | + +### Coexistence with other patterns + +- **Tap-outside-to-close** stays wired via the existing backdrop `
` — independent code path, unaffected by the gesture hook. +- **Tap-on-handle** is treated as a no-op (touch starts and ends inside the dead-zone → no offset → no commit). `hasInteracted` does flip true, but with `dragOffset === 0` the inline transform stays at `translateY(0)` and the visible state matches the open state. +- **iOS pull-to-refresh** is blocked because `touchmove` is non-passive and calls `preventDefault()` once we cross the dead-zone. +- **Internal scrolling** (e.g. emoji grid, GIF results, folder space list) is **untouched** — `handleProps.onTouchStart` is bound to the header element only, so scroll containers below it never enter drag mode. +- **Open animation** (`animate-slide-up-sheet` for `MobileFolderSheet` / `InputPopover.MobileSheet`, the `translate-y-full → translate-y-0` flip for `MobileVoiceJoinSheet`) is gated by `!hasInteracted`. After the first touch, the open class never re-applies for the rest of the sheet's lifetime — the inline `transform` + transition becomes the sole animator for both snap-back and close-out. --- diff --git a/packages/web/index.html b/packages/web/index.html index 71d407eb..1a29dc55 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -3,7 +3,7 @@ - + diff --git a/packages/web/src/components/chat/AttachmentRenderer.tsx b/packages/web/src/components/chat/AttachmentRenderer.tsx index 4f6e6132..56c597fc 100644 --- a/packages/web/src/components/chat/AttachmentRenderer.tsx +++ b/packages/web/src/components/chat/AttachmentRenderer.tsx @@ -162,6 +162,16 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) { ); } + // Generic-file chip (PDF / .zip / .exe / unknown mimetypes). + // + // Width contract: the chip must fit inside the message column on every + // viewport. We cap at 400 px on roomy layouts but `max-w-full` keeps it + // inside narrow columns (mobile, narrow desktop window, threaded reply + // contexts). `min-w-0` is the critical bit on the inner flex children — the + // outer button is a flex container with a fixed-size icon and a flexible + // text block; without `min-w-0` the long-filename child would refuse to + // shrink (flex children's min-content size defaults to their intrinsic + // content) and would push the entire chip past the parent's right edge. return (