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).
246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
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';
|
|
|
|
interface InputPopoverProps {
|
|
activeTab: InputPopoverTab;
|
|
onClose: () => void;
|
|
onEmojiSelect: (emoji: { native: string }) => void;
|
|
onGifSelect: (url: string) => void;
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
gifEnabled: boolean;
|
|
onTabChange: (tab: InputPopoverTab) => void;
|
|
}
|
|
|
|
interface SharedTabProps {
|
|
activeTab: InputPopoverTab;
|
|
availableTabs: { key: InputPopoverTab; label: string }[];
|
|
onTabChange: (tab: InputPopoverTab) => void;
|
|
}
|
|
|
|
function TabBar({ activeTab, availableTabs, onTabChange }: SharedTabProps) {
|
|
if (availableTabs.length <= 1) return null;
|
|
return (
|
|
<div className="flex items-center gap-0.5 px-2 pt-2 pb-1">
|
|
{availableTabs.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => onTabChange(t.key)}
|
|
className={`px-3 py-1 rounded-md text-[13px] font-medium transition-colors ${
|
|
activeTab === t.key
|
|
? 'bg-interactive-selected text-txt-primary'
|
|
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-interactive-hover'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DesktopPopoverProps extends InputPopoverProps {
|
|
availableTabs: { key: InputPopoverTab; label: string }[];
|
|
}
|
|
|
|
function DesktopPopover({
|
|
activeTab,
|
|
onClose,
|
|
onEmojiSelect,
|
|
onGifSelect,
|
|
anchorRef,
|
|
gifEnabled,
|
|
onTabChange,
|
|
availableTabs,
|
|
}: DesktopPopoverProps) {
|
|
const floatingRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Position above the anchor
|
|
const updatePosition = useCallback(() => {
|
|
const anchor = anchorRef.current;
|
|
const floating = floatingRef.current;
|
|
if (!anchor || !floating) return;
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const floatingRect = floating.getBoundingClientRect();
|
|
const vw = window.innerWidth;
|
|
|
|
let left = anchorRect.right - floatingRect.width;
|
|
let top = anchorRect.top - floatingRect.height - 8;
|
|
|
|
// Flip below if no room above
|
|
if (top < 8) {
|
|
top = anchorRect.bottom + 8;
|
|
}
|
|
|
|
// Clamp horizontal
|
|
left = Math.max(8, Math.min(left, vw - floatingRect.width - 8));
|
|
|
|
floating.style.top = `${top}px`;
|
|
floating.style.left = `${left}px`;
|
|
}, [anchorRef]);
|
|
|
|
useEffect(() => {
|
|
updatePosition();
|
|
window.addEventListener('resize', updatePosition);
|
|
window.addEventListener('scroll', updatePosition, true);
|
|
return () => {
|
|
window.removeEventListener('resize', updatePosition);
|
|
window.removeEventListener('scroll', updatePosition, true);
|
|
};
|
|
}, [updatePosition, activeTab]);
|
|
|
|
// Re-position after the picker renders (it may change height)
|
|
useEffect(() => {
|
|
const frame = requestAnimationFrame(updatePosition);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [activeTab, updatePosition]);
|
|
|
|
// Click outside to close
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
const floating = floatingRef.current;
|
|
const anchor = anchorRef.current;
|
|
if (!floating) return;
|
|
if (floating.contains(e.target as Node)) return;
|
|
if (anchor && anchor.contains(e.target as Node)) return;
|
|
onClose();
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [onClose, anchorRef]);
|
|
|
|
// Escape to close
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [onClose]);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={floatingRef}
|
|
className="fixed z-[300] animate-slide-up"
|
|
style={{ top: -9999, left: -9999 }}
|
|
>
|
|
<div className="glass rounded-xl overflow-hidden flex flex-col w-fit max-h-[435px]">
|
|
<TabBar activeTab={activeTab} availableTabs={availableTabs} onTabChange={onTabChange} />
|
|
{/* Content */}
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
interface MobileSheetProps extends InputPopoverProps {
|
|
availableTabs: { key: InputPopoverTab; label: string }[];
|
|
}
|
|
|
|
function MobileSheet({
|
|
activeTab,
|
|
onClose,
|
|
onEmojiSelect,
|
|
onGifSelect,
|
|
gifEnabled,
|
|
onTabChange,
|
|
availableTabs,
|
|
}: MobileSheetProps) {
|
|
// Escape to close (parity with desktop)
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
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 */}
|
|
<div
|
|
className="fixed inset-0 z-[300] bg-black/30"
|
|
onMouseDown={onClose}
|
|
onTouchStart={onClose}
|
|
/>
|
|
{/* Sheet */}
|
|
<div
|
|
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;
|
|
// on older iOS the 100dvh-based MobileShell layout already shrinks
|
|
// the visual viewport when the keyboard is open, so bottom:0 lands
|
|
// just above the keyboard naturally.
|
|
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 + 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">
|
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
|
</div>
|
|
</div>
|
|
</>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
export function InputPopover(props: InputPopoverProps) {
|
|
const isMobile = useUIStore((s) => s.isMobile);
|
|
|
|
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
|
{ key: 'emoji', label: 'Emoji' },
|
|
];
|
|
if (props.gifEnabled) {
|
|
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
|
}
|
|
|
|
if (isMobile) {
|
|
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
|
}
|
|
return <DesktopPopover {...props} availableTabs={availableTabs} />;
|
|
}
|