diff --git a/app/(app)/chats/[id]/page.tsx b/app/(app)/chats/[id]/page.tsx index 5edbcb98..6d489b29 100644 --- a/app/(app)/chats/[id]/page.tsx +++ b/app/(app)/chats/[id]/page.tsx @@ -33,6 +33,7 @@ import { ForwardDialog } from "@/components/chat/forward-dialog"; import { toast } from "sonner"; import { channelAuthorizer } from "@/lib/realtime"; import { cn } from "@/lib/utils"; +import { useVisualViewport } from "@/lib/use-visual-viewport"; import { chat, conversationTitle, @@ -63,6 +64,13 @@ export default function ChatThreadPage() { const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null); const [forwarding, setForwarding] = useState(null); const [starredIds, setStarredIds] = useState>(new Set()); + + // While the keyboard is up the thread is sized from the visual viewport + // instead of dvh — see useVisualViewport for why dvh can't do this on iOS. + // Declared with the other hooks: the early return below means anything + // placed after it would run conditionally. + const viewport = useVisualViewport(); + const keyboardOpen = viewport?.keyboardOpen ?? false; // Guards against two loads racing (scroll handler + the short-page effect). const loadingRef = useRef(false); @@ -429,8 +437,30 @@ export default function ChatThreadPage() { // space, and calc() requires whitespace around a binary `-` — written // without it the whole declaration is invalid and dropped, which // collapses this to height:auto. - "h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]", + // Only while the keyboard is closed; otherwise the inline style below + // takes over and these would fight it. + !keyboardOpen && + "h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]", )} + style={ + keyboardOpen && viewport + ? { + // Pinned to the visible strip above the keyboard. Fixed rather + // than in-flow because iOS scrolls the layout viewport instead + // of resizing it, so an in-flow column keeps its old height and + // hides the composer underneath. left/right 0 with the existing + // mx-auto keeps the desktop column centred. + position: "fixed", + top: viewport.offsetTop, + left: 0, + right: 0, + height: viewport.height, + // Above the app's fixed bottom nav (z-40) so the composer can + // never be painted over by it. + zIndex: 50, + } + : undefined + } >
void; }) { const { user } = useAuth(); + const isMobile = useMediaQuery("(max-width: 767px)"); const [query, setQuery] = useState(""); // Results are stored with the query they belong to, so what's displayed can // be derived: a stale page from a previous query simply stops matching, and @@ -68,12 +72,6 @@ export function AddMembersDialog({ return () => clearTimeout(timer); }, [q, searchable]); - useEffect(() => { - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - const toggle = (handle: string) => setPicked((prev) => { const next = new Set(prev); @@ -100,85 +98,70 @@ export function AddMembersDialog({ }; return ( -
-
-
-
-

Add members

- + + + setQuery(e.target.value)} + placeholder="Search by name or handle" + className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground" + />
- -
-
- - setQuery(e.target.value)} - placeholder="Search by name or handle" - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- -
- {!searchable ? ( -

- Type at least two characters to search. -

- ) : visible.length === 0 ? ( -

No one found.

- ) : ( -
    - {visible.map((u) => ( -
  • - -
  • - ))} -
- )} -
- -
- -
-
-
+ } + footer={ + + } + > + {!searchable ? ( +

+ Type at least two characters to search. +

+ ) : visible.length === 0 ? ( +

No one found.

+ ) : ( +
    + {visible.map((u) => ( +
  • + +
  • + ))} +
+ )} + ); } diff --git a/components/chat/chat-composer.tsx b/components/chat/chat-composer.tsx index 6be47ec2..18e2d0dd 100644 --- a/components/chat/chat-composer.tsx +++ b/components/chat/chat-composer.tsx @@ -271,7 +271,12 @@ export function ChatComposer({ submit(); }} className={cn( - "flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm", + // 16px on touch devices: iOS Safari zooms the whole page when a + // focused field computes below that, which on a composer means the + // thread jumps every time the keyboard opens. Pointer-based devices + // keep the app's 14px chrome. + "flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2", + "text-sm pointer-coarse:text-base", "placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40", "disabled:opacity-60", )} diff --git a/components/chat/group-settings-tab.tsx b/components/chat/group-settings-tab.tsx index 850339be..92689c5a 100644 --- a/components/chat/group-settings-tab.tsx +++ b/components/chat/group-settings-tab.tsx @@ -175,7 +175,7 @@ export function GroupSettingsTab({ value={title} onChange={(e) => setTitle(e.target.value)} maxLength={120} - className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40" + className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40" /> -
- -
+ setTitle(e.target.value)} maxLength={120} placeholder="Group name" - className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40" + className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40" />
@@ -115,7 +105,7 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) { value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Add people" - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground" />
@@ -135,54 +125,51 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
)} - -
- {!searchable ? ( -

- Search to add people. You can also add them later. -

- ) : visible.length === 0 ? ( -

No one found.

- ) : ( -
    - {visible.map((u) => ( -
  • - -
  • - ))} -
- )} -
- -
- -
- - + } + footer={ + + } + > + {!searchable ? ( +

+ Search to add people. You can also add them later. +

+ ) : visible.length === 0 ? ( +

No one found.

+ ) : ( +
    + {visible.map((u) => ( +
  • + +
  • + ))} +
+ )} + ); } diff --git a/components/chat/responsive-dialog.tsx b/components/chat/responsive-dialog.tsx new file mode 100644 index 00000000..b5d60f97 --- /dev/null +++ b/components/chat/responsive-dialog.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * The shell the chat dialogs sit in: a bottom drawer on mobile, a centred + * modal on desktop. + * + * Extracted because "Add members" and "New group" are the same shape — title, + * a fixed toolbar of inputs, a scrolling list of people, a confirm button — + * and each would otherwise carry its own copy of the drawer/modal split. + * + * Follows the pattern already used by ReportDialog and ShareModal: the same + * `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it, + * and a portalled modal above. + */ + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X } from "lucide-react"; +import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; + +export function ResponsiveDialog({ + title, + description, + onClose, + toolbar, + footer, + children, +}: { + title: string; + description: string; + onClose: () => void; + /** Fixed block under the title — search fields, selected chips. */ + toolbar?: ReactNode; + /** Pinned action row at the bottom. */ + footer?: ReactNode; + /** The scrolling region between toolbar and footer. */ + children: ReactNode; +}) { + const isMobile = useMediaQuery("(max-width: 767px)"); + + // Desktop only: Escape to dismiss, and lock the page behind the modal. The + // drawer does both itself, and doubling up on the scroll lock fights vaul's + // own body handling on iOS. + useEffect(() => { + if (isMobile) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [isMobile, onClose]); + + if (isMobile) { + return ( + !o && onClose()}> + + + {title} + {description} + + {toolbar &&
{toolbar}
} + {/* Capped rather than flex-1: a drawer sizes to its content, so an + unbounded list would grow the sheet past the screen. */} +
+ {children} +
+ {footer &&
{footer}
} +
+
+ ); + } + + if (typeof document === "undefined") return null; + return createPortal( + <> +
+
+
+
+
+

+ {title} +

+

+ {description} +

+
+ +
+ {toolbar &&
{toolbar}
} +
{children}
+ {footer &&
{footer}
} +
+
+ , + document.body, + ); +} diff --git a/lib/use-visual-viewport.ts b/lib/use-visual-viewport.ts new file mode 100644 index 00000000..7672484a --- /dev/null +++ b/lib/use-visual-viewport.ts @@ -0,0 +1,63 @@ +"use client"; + +/** + * Tracks the *visual* viewport — the part of the page not covered by the + * software keyboard. + * + * Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard + * opens. The dynamic viewport units respond to browser chrome collapsing, not + * to the keyboard, so a composer pinned to the bottom of a `100dvh` column + * ends up underneath it. `window.visualViewport` is the only thing iOS updates + * reliably, so the thread is sized from that instead while the keyboard is up. + * + * `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to + * reveal the focused field rather than resizing it, so a panel pinned to the + * visible area has to follow that offset or it drifts off-screen. + */ + +import { useEffect, useState } from "react"; + +export type VisualViewport = { + /** Visible height in px, excluding the keyboard. */ + height: number; + /** How far the layout viewport has scrolled to reveal the focused field. */ + offsetTop: number; + /** True once the visible area is materially shorter than the window. */ + keyboardOpen: boolean; +}; + +/** + * A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The + * threshold keeps ordinary chrome changes from being read as a keyboard. + */ +const KEYBOARD_THRESHOLD_PX = 120; + +export function useVisualViewport(): VisualViewport | null { + const [state, setState] = useState(null); + + useEffect(() => { + const vv = window.visualViewport; + // Absent on older browsers — callers fall back to their CSS height. + if (!vv) return; + + const apply = () => { + setState({ + height: vv.height, + offsetTop: vv.offsetTop, + keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX, + }); + }; + + apply(); + vv.addEventListener("resize", apply); + // iOS fires scroll (not resize) as the layout viewport shifts under the + // keyboard, so both are needed to stay pinned. + vv.addEventListener("scroll", apply); + return () => { + vv.removeEventListener("resize", apply); + vv.removeEventListener("scroll", apply); + }; + }, []); + + return state; +}