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 */}
|
||||
|
||||
Reference in New Issue
Block a user