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:
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
@@ -173,9 +183,9 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
|
||||
tray: true,
|
||||
});
|
||||
}}
|
||||
className="mt-1 max-w-[400px] flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all group/att text-left w-full"
|
||||
className="mt-1 max-w-full sm:max-w-[400px] flex items-center gap-3 p-4 bg-surface-channel/50 rounded-lg border border-border-hard hover:bg-interactive-hover transition-all group/att text-left w-full min-w-0"
|
||||
>
|
||||
<div className="p-2 bg-surface-base rounded text-txt-tertiary group-hover/att:text-txt-primary transition-colors">
|
||||
<div className="p-2 bg-surface-base rounded text-txt-tertiary group-hover/att:text-txt-primary transition-colors flex-shrink-0">
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -185,9 +195,9 @@ export function AttachmentRenderer({ attachment }: AttachmentRendererProps) {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-txt-link text-[15px] font-medium truncate hover:underline">{originalName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-[12px] text-txt-tertiary font-medium">{formatFileSize(size)}</p>
|
||||
{federationInlineBadge}
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useComposerStore } from '../../stores/composerStore';
|
||||
import { useTransferStore, type Transfer } from '../../stores/transferStore';
|
||||
import { usePendingMessageStore } from '../../stores/pendingMessageStore';
|
||||
import { putHandle, supportsFsHandles, supportsDnDHandles } from '../../utils/idbHandles';
|
||||
import { useVisualViewportInset } from '../../hooks/useVisualViewportInset';
|
||||
|
||||
interface MessageInputProps {
|
||||
channelId: string;
|
||||
@@ -60,7 +61,13 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||
// Note: this ref is intentionally typed `HTMLDivElement | null` (mutable
|
||||
// ref shape) rather than the more restrictive `RefObject<HTMLDivElement>`
|
||||
// because we assign to `.current` from a callback ref below — the
|
||||
// callback ref bridges the imperative `popoverAnchorRef` consumers
|
||||
// (InputPopover / mention-popover anchoring) and the state-backed
|
||||
// `composerEl` slot used by the clearance-measuring effect.
|
||||
const popoverAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Object URLs for current-session image previews. transferStore doesn't hold
|
||||
// the raw File, so previews only exist for files picked in this session
|
||||
@@ -566,12 +573,148 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
!isOverLimit &&
|
||||
!anyUnshippable;
|
||||
|
||||
// Composer positioning model — IDENTICAL across desktop and mobile.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// The composer is a floating glass-bubble (`glass-bubble rounded-[14px]`)
|
||||
// pinned to the bottom of the chat region with `position: absolute`. The
|
||||
// MessageList sibling fills the entire chat area; the last messages scroll
|
||||
// *behind* the translucent bubble. MessageList content carries a dynamic
|
||||
// `paddingBottom` (CSS variable `--composer-clearance`, set by the
|
||||
// ResizeObserver effect below) so the last message clears the bubble's
|
||||
// top edge with a 12 px breathing gap regardless of bubble height.
|
||||
//
|
||||
// Vertical positioning differs only in the `bottom` value:
|
||||
// - Desktop: `bottom: 12px` (the historical `md:bottom-3` constant).
|
||||
// - Mobile, keyboard closed: `bottom: env(safe-area-inset-bottom) + 6px`
|
||||
// so the bubble clears the iOS home indicator with a small breathing gap.
|
||||
// - Mobile, keyboard open: `bottom: 0`. `MobileShell` shrinks its container
|
||||
// to `visualViewport.height` (see `MobileShell.tsx`), so the chat region's
|
||||
// bottom edge already sits on the keyboard's top edge. The composer then
|
||||
// lands flush with the keyboard regardless of how reliably
|
||||
// `visualViewport` event delivery is on iOS PWA — the shell's height
|
||||
// shrinking is the load-bearing mechanism, not the inset arithmetic
|
||||
// here. This dodges the long-standing iOS-standalone bug where
|
||||
// `visualViewport.resize` fires late or not at all when the soft
|
||||
// keyboard opens. The hook's `focusin` polling fallback covers the
|
||||
// remaining gap by re-reading `vv.height` for ~600 ms after a text
|
||||
// input gains focus, even when no resize event ever lands.
|
||||
//
|
||||
// The horizontal inset is symmetric: `left-2 right-2` on mobile (matches
|
||||
// `MobileVoiceMiniBar`'s `mx-2` and the `MobileBottomNav` spacing tier);
|
||||
// `md:left-3 md:right-3` on desktop (the historical 12 px inset).
|
||||
//
|
||||
// `z-[110]` keeps the bubble above any in-chat overlays (mention popover,
|
||||
// staged-attachment tiles) but below modals (`z-[300]+`).
|
||||
const isMobile = useUIStore((s) => s.isMobile);
|
||||
const { keyboardOpen } = useVisualViewportInset();
|
||||
const composerStyle: React.CSSProperties | undefined = isMobile
|
||||
? { bottom: keyboardOpen ? '0px' : 'calc(env(safe-area-inset-bottom) + 6px)' }
|
||||
: undefined;
|
||||
const composerClass =
|
||||
'absolute left-2 right-2 z-[110] glass-bubble rounded-[14px]' +
|
||||
' md:left-3 md:right-3 md:bottom-3';
|
||||
|
||||
// Dynamic message-list bottom padding ("composer clearance"):
|
||||
//
|
||||
// The composer is `position: absolute` and overlays the bottom of the
|
||||
// chat region. The MessageList scroll content needs enough bottom padding
|
||||
// that the last message can be scrolled fully into view above the bubble
|
||||
// with a visible gap — otherwise the last message sticks flush to the
|
||||
// bubble's top edge (the bug user reported on iOS PWA: a static `pb-20`
|
||||
// = 80 px is smaller than `composer-bottom-offset (env safe-area + 6) +
|
||||
// composer-height (~50–100 px depending on staged attachments / multi-
|
||||
// line text)` on iPhone).
|
||||
//
|
||||
// Strategy: a single CSS custom property `--composer-clearance` is
|
||||
// written to the nearest scrollable ancestor on every composer-size or
|
||||
// composer-bottom-offset change. `MessageList` reads that variable as
|
||||
// its content's `paddingBottom`, falling back to a static 80 px when
|
||||
// unset (e.g. when no composer is mounted, or before the first measure).
|
||||
// The 12 px constant below is the desired breathing-room gap between the
|
||||
// last message's bottom edge and the composer's top edge.
|
||||
//
|
||||
// Why a CSS variable on the parent rather than a global:
|
||||
// - One MessageInput per chat region; the variable scopes to that
|
||||
// region so multi-pane layouts (DM list + chat in a future split
|
||||
// view, voice channel side-panel, etc.) don't cross-talk.
|
||||
// - The MessageList content already lives inside the same parent
|
||||
// subtree, so a CSS variable inheritance just works.
|
||||
// We track the live composer DOM element via a state-backed ref. A plain
|
||||
// ref isn't enough because the component renders different JSX when
|
||||
// `canSendMessages` flips (the early-return permission-denied path doesn't
|
||||
// attach the ref), and a useEffect on the ref's value would not re-fire on
|
||||
// those re-renders. Channel permissions arrive asynchronously, so the
|
||||
// initial mount renders the no-permission JSX first, then re-renders with
|
||||
// the full composer once permissions resolve — we need to (re-)attach the
|
||||
// ResizeObserver at that moment.
|
||||
const [composerEl, setComposerEl] = useState<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!composerEl) return;
|
||||
const target = composerEl.parentElement;
|
||||
if (!target) return;
|
||||
const el = composerEl;
|
||||
|
||||
const sync = () => {
|
||||
// Total clearance = composer height + bottom offset + 12 px gap.
|
||||
// We measure the bubble's visual height (including replyTo banner +
|
||||
// staged-attachment tiles + textarea autosize) plus the distance from
|
||||
// the parent's bottom edge to the bubble's bottom edge (which folds
|
||||
// in `env(safe-area-inset-bottom) + 6` on mobile or `12 px` on
|
||||
// desktop, whichever the composer's `bottom` resolves to).
|
||||
const composerRect = el.getBoundingClientRect();
|
||||
const parentRect = target.getBoundingClientRect();
|
||||
const bottomOffset = Math.max(0, parentRect.bottom - composerRect.bottom);
|
||||
const clearance = Math.round(composerRect.height + bottomOffset + 12);
|
||||
target.style.setProperty('--composer-clearance', `${clearance}px`);
|
||||
};
|
||||
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
ro.observe(el);
|
||||
// Also re-sync when the parent itself resizes (keyboard open/close
|
||||
// collapses the chat region's height; MobileShell drives this via
|
||||
// visualViewport.height).
|
||||
ro.observe(target);
|
||||
|
||||
// Re-sync on visual viewport changes — the parent's `getBoundingClientRect`
|
||||
// updates with the layout, but if `MobileShell`'s height attribute
|
||||
// updates between paints, we want a same-frame re-measure.
|
||||
const vv = window.visualViewport;
|
||||
const onVv = () => sync();
|
||||
if (vv) {
|
||||
vv.addEventListener('resize', onVv);
|
||||
vv.addEventListener('scroll', onVv);
|
||||
}
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (vv) {
|
||||
vv.removeEventListener('resize', onVv);
|
||||
vv.removeEventListener('scroll', onVv);
|
||||
}
|
||||
target.style.removeProperty('--composer-clearance');
|
||||
};
|
||||
// Re-arm the observer / listeners when keyboard transitions or the
|
||||
// composer's content materially changes — the dependency list is the
|
||||
// set of inputs that can change the bubble's height or its bottom
|
||||
// offset between renders. The ResizeObserver itself is what catches
|
||||
// continuous textarea-autosize growth; these deps just ensure we're
|
||||
// attached to the live element after a remount.
|
||||
}, [composerEl, isMobile, keyboardOpen, chatReplyTo, stagedTransfers.length]);
|
||||
|
||||
// Combined ref: keep `popoverAnchorRef` populated (InputPopover / mention
|
||||
// popover anchor + scroll-into-view targets) AND notify the
|
||||
// `composerEl` state slot so the clearance-measuring effect can re-run
|
||||
// when the element materializes / changes between conditional render
|
||||
// branches.
|
||||
const setComposerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
popoverAnchorRef.current = node;
|
||||
setComposerEl(node);
|
||||
}, []);
|
||||
|
||||
if (!canSendMessages) {
|
||||
return (
|
||||
<div
|
||||
data-pip-obstacle="bottom"
|
||||
className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]"
|
||||
>
|
||||
<div ref={setComposerRef} data-pip-obstacle="bottom" className={composerClass} style={composerStyle}>
|
||||
<div className="flex items-center justify-center py-[14px] px-4">
|
||||
<span className="text-txt-tertiary text-[14px]">
|
||||
You do not have permission to send messages in this channel
|
||||
@@ -583,9 +726,10 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverAnchorRef}
|
||||
ref={setComposerRef}
|
||||
data-pip-obstacle="bottom"
|
||||
className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]"
|
||||
className={composerClass}
|
||||
style={composerStyle}
|
||||
>
|
||||
<TypingIndicator channelId={channelId} />
|
||||
|
||||
@@ -623,7 +767,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
)}
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className={`relative bg-surface-input md:bg-transparent ${chatReplyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||
className={`relative ${chatReplyTo ? 'rounded-b-lg' : ''} overflow-visible`}
|
||||
onDrop={canAttachFiles ? handleDrop : undefined}
|
||||
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
||||
>
|
||||
|
||||
@@ -657,7 +657,11 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
|
||||
|
||||
{!hasMore && <WelcomeHeader channelId={channelId} />}
|
||||
|
||||
<div ref={contentRef} className="pt-4 pb-6 md:pb-20">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="pt-4"
|
||||
style={{ paddingBottom: 'var(--composer-clearance, 80px)' }}
|
||||
>
|
||||
{interleavedMessages.map((msg, i) => {
|
||||
const prevMsg = interleavedMessages[i - 1];
|
||||
const showDate = shouldShowDateDivider(prevMsg, msg);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user