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
@@ -5,7 +5,6 @@ import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { TransferIndicator } from './TransferIndicator';
import { parseFederatedUsername } from '../../utils/identity';
import { useCanonicalUserView } from '../../utils/userViewLookup';
@@ -95,14 +94,21 @@ export function MobileChatScreen({ params }: MobileChatScreenProps) {
)}
</header>
{/* Messages */}
<div className="flex-1 overflow-hidden min-h-0 flex flex-col">
{/* Messages + floating composer.
Mirrors the desktop pattern in `MainContent.tsx`: a single relative
flex-1 region holds both `<MessageList>` (filling the area) and
`<MessageInput>` (floating glass-bubble at the bottom). The bubble
is `position: absolute` and is positioned from `MessageInput.tsx`
via the `useVisualViewportInset` hook so it lifts above the iOS
soft keyboard when one is open and rests above the home-indicator
safe-area when not. MessageList content carries `pb-20` so the last
message can scroll fully into view above the bubble.
TypingIndicator is rendered inside MessageInput itself (anchored
`absolute bottom-full` to the bubble), so we don't render it here. */}
<div className="relative flex-1 min-h-0 flex flex-col overflow-hidden">
{channelId && <MessageList channelId={channelId} />}
{channelId && <MessageInput channelId={channelId} channelName={channelName} />}
</div>
{/* Typing indicator + Input */}
{channelId && <TypingIndicator channelId={channelId} />}
{channelId && <MessageInput channelId={channelId} channelName={channelName} />}
</div>
);
}
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import type { SpaceFolder } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore';
import { useContextMenuStore } from '../../stores/contextMenuStore';
import { useDragToClose } from '../../hooks/useDragToClose';
import { getSpaceGradient } from '../../utils/gradients';
const FOLDER_COLORS = [
@@ -84,17 +85,31 @@ export function MobileFolderSheet({ folder, onClose, onSelectSpace, onUpdateFold
]);
};
// Drag-down-to-close. Touches on the handle pill or the folder-header row
// initiate the gesture; the scrollable space-list is unaffected.
// `hasInteracted` flips on the first touchstart and stays true so the
// open keyframe doesn't re-run during snap-back / close-out.
const { sheetStyle: dragStyle, handleProps: dragHandleProps, hasInteracted } =
useDragToClose({ onClose });
return (
<>
<div className="fixed inset-0 z-[300] bg-black/50" onClick={onClose} />
<div
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-modal animate-slide-up-sheet max-h-[60vh] flex flex-col"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
className={`fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-modal max-h-[60vh] flex flex-col ${
hasInteracted ? '' : 'animate-slide-up-sheet'
}`}
style={{ paddingBottom: 'env(safe-area-inset-bottom)', ...dragStyle }}
>
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1 shrink-0" />
{/* Drag handle + folder header — both belong to the drag-to-close
region. The folder-header row keeps its right-click → context menu
(`onContextMenu`); only vertical drag past the dead-zone is
captured by the gesture. */}
<div {...dragHandleProps} className="shrink-0 touch-none">
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
{/* Folder header */}
<div className="px-4 py-2 flex items-center gap-2 shrink-0" data-context-menu onContextMenu={handleFolderContextMenu}>
<div className="px-4 py-2 flex items-center gap-2" data-context-menu onContextMenu={handleFolderContextMenu}>
<div
className="w-5 h-5 rounded"
style={{ background: folder.color || 'rgb(var(--text-tertiary))' }}
@@ -115,6 +130,7 @@ export function MobileFolderSheet({ folder, onClose, onSelectSpace, onUpdateFold
)}
<span className="text-xs text-txt-tertiary">{folderSpaces.length} spaces</span>
</div>
</div>
{/* Folder spaces */}
<div className="flex-1 overflow-y-auto px-2 py-1">
@@ -5,6 +5,7 @@ import { useLocation } from 'react-router-dom';
import { MobileScreenStack } from './MobileScreenStack';
import { MobileBottomNav } from './MobileBottomNav';
import { useSwipeGesture } from '../../hooks/useSwipeGesture';
import { useVisualViewportInset } from '../../hooks/useVisualViewportInset';
import { MobileSpacesScreen } from './MobileSpacesScreen';
import { MobileDmsScreen } from './MobileDmsScreen';
@@ -182,8 +183,19 @@ export function MobileShell() {
you: <MobileYouScreen />,
};
// Size the shell to the visual viewport when the iOS soft keyboard is open
// so a `position: absolute; bottom: 0` child (the chat composer) lands on
// the keyboard's top edge — independent of how reliably the
// `visualViewport.resize` event fires in standalone PWA mode. When the
// keyboard is closed we use `100dvh` so the shell extends through the
// home-indicator safe area as designed. See `useVisualViewportInset` for
// the iOS-PWA-specific fallback (focusin polling) that updates `height`
// even when no `resize` event ever lands.
const { keyboardOpen, height: vvHeight } = useVisualViewportInset();
const shellHeight = keyboardOpen && vvHeight !== null ? `${vvHeight}px` : '100dvh';
return (
<div className="flex flex-col" style={{ height: '100dvh' }}>
<div className="flex flex-col" style={{ height: shellHeight }}>
<MobileScreenStack
rootScreen={rootScreens[mobileScreen]}
screenMap={screenMap}