diff --git a/artifacts/tx-os/src/components/notes/send-note-dialog.tsx b/artifacts/tx-os/src/components/notes/send-note-dialog.tsx index 9c656115..e4fef70f 100644 --- a/artifacts/tx-os/src/components/notes/send-note-dialog.tsx +++ b/artifacts/tx-os/src/components/notes/send-note-dialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Search, Send } from "lucide-react"; import { useVisualViewport } from "@/hooks/use-visual-viewport"; @@ -43,17 +43,94 @@ export function SendNoteDialog({ const send = useSendNote(); const [search, setSearch] = useState(""); const [picked, setPicked] = useState>(new Set()); - // Make the recipients list shrink to fit when the iPad soft keyboard - // takes over the bottom half of the screen. Without this the dialog - // keeps its full height, gets centred under the keyboard, and the - // people list ends up hidden — same root cause as #581 / the toolbar - // fix in editable-cell.tsx (visualViewport excludes the keyboard but - // not the QuickType strip, so we add a 48px buffer when the keyboard - // is up). + // #630: Keep the recipients list and the Send button visible above + // the iOS soft keyboard. + // + // Background: the previous fix relied on `useVisualViewport` to + // shrink `listMaxHeight` while the keyboard was up. That works on + // desktop Safari and on iPad Safari (regular tab), but iPad PWAs + // (Add to Home Screen) do NOT reliably resize visualViewport when + // the soft keyboard opens — see the comment block in + // `components/ui/dialog.tsx` and the reverted attempts #622–#624. + // So the dialog stayed full height, kept its Radix center anchor + // (top: 50%; translate(-50%, -50%)), and the keyboard covered the + // entire bottom half — list and Send button included. + // + // The targeted, per-dialog fix here: + // • Track focus on the search input directly. Focus is the most + // reliable signal that the keyboard is (or is about to be) up, + // and it doesn't depend on visualViewport reporting anything. + // • While focused, anchor the dialog to the TOP of the viewport + // instead of the center, and cap its height to ~50dvh. That + // guarantees the entire dialog (title → input → list → buttons) + // fits in the upper half of the screen, leaving the lower half + // free for the keyboard regardless of what visualViewport says. + // • When focus leaves, drop back to Radix's normal centered layout + // so desktop and not-typing iPad keep the original look. + // + // `useVisualViewport` is still consulted as a refinement on devices + // where it DOES update (desktop touch laptops, Android Chrome, iPad + // Safari tab) so the list cap matches the actual usable height. const vv = useVisualViewport(); - const KEYBOARD_PREDICTION_INSET = vv.keyboardInset > 0 ? 48 : 0; + const [searchFocused, setSearchFocused] = useState(false); + // Device guard — only the focus-driven top-anchored override should + // fire on touch devices where a soft keyboard actually appears. + // Desktops with a hardware keyboard have no overlay to dodge, and + // architect review flagged that applying the override on every + // focus regressed their layout. `(pointer: coarse)` matches iPad, + // iPhone, and Android touch primary inputs while excluding mouse- + // driven desktop and touch laptops where the primary pointer is a + // trackpad/mouse. + const [isTouchPrimary, setIsTouchPrimary] = useState(false); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(pointer: coarse)"); + setIsTouchPrimary(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsTouchPrimary(e.matches); + mq.addEventListener?.("change", handler); + return () => mq.removeEventListener?.("change", handler); + }, []); + const keyboardLikely = searchFocused && isTouchPrimary; + // Slight delay on blur so the user can tap a list row without the + // dialog jumping back to center mid-tap (a focus/blur race that + // would shift the row out from under the finger). + const blurTimeoutRef = useRef(null); + // Lifecycle cleanup — clear a pending blur timeout on unmount so we + // don't schedule a setState on an unmounted component (and to satisfy + // the architect's nitpick about the missing teardown). + useEffect(() => { + return () => { + if (blurTimeoutRef.current !== null) { + window.clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = null; + } + }; + }, []); + const onSearchFocus = () => { + if (blurTimeoutRef.current !== null) { + window.clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = null; + } + setSearchFocused(true); + }; + const onSearchBlur = () => { + blurTimeoutRef.current = window.setTimeout(() => { + setSearchFocused(false); + blurTimeoutRef.current = null; + }, 150); + }; + + const KEYBOARD_PREDICTION_INSET = + vv.keyboardInset > 0 || keyboardLikely ? 48 : 0; const listMaxHeight = (() => { - const usable = (vv.height || window.innerHeight) - KEYBOARD_PREDICTION_INSET; + // Only assume the ~50% usable bound on touch devices where a + // soft keyboard is actually about to appear. Desktops keep using + // the real viewport height (or visualViewport when it updates). + const baseHeight = vv.height || window.innerHeight; + const assumedUsable = keyboardLikely + ? Math.min(baseHeight, window.innerHeight * 0.5) + : baseHeight; + const usable = assumedUsable - KEYBOARD_PREDICTION_INSET; // Reserve ~220px for the dialog chrome (title + search input + // footer + paddings) so the list never pushes the buttons off // screen. Clamp to a sensible min/max so desktop keeps the old @@ -62,6 +139,21 @@ export function SendNoteDialog({ return forList; })(); + // Override DialogContent's centered positioning while typing on + // touch devices: anchor to top with `translate(-50%, 0)` so the + // dialog grows downward from a fixed top edge and never extends + // past the midpoint of the screen (where the soft keyboard sits). + // On desktop and other non-touch contexts, leave Radix's default + // centered layout alone. + const dialogStyle: React.CSSProperties = keyboardLikely + ? { + top: "6vh", + transform: "translate(-50%, 0)", + maxHeight: "50dvh", + transition: "top 120ms ease, transform 120ms ease", + } + : { transition: "top 120ms ease, transform 120ms ease" }; + const candidates = useMemo(() => { const me = user?.id; return directory.filter((u) => u.id !== me); @@ -123,7 +215,11 @@ export function SendNoteDialog({ return ( !o && onClose()}> - + {t("notes.sendNote", "Send note")} @@ -135,6 +231,8 @@ export function SendNoteDialog({ setSearch(e.target.value)} + onFocus={onSearchFocus} + onBlur={onSearchBlur} placeholder={t("notes.recipientsPlaceholder", "Search people…")} className="ps-9" data-testid="send-note-search"