From 744b7814f57e3e420c1a3d351a1721397010f1fa Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 24 May 2026 09:45:38 +0000 Subject: [PATCH] Task #630: keep recipients list visible above iPad keyboard in Send Note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: opening Send Note on iPad PWA, tapping the search field popped up the soft keyboard which covered the entire people list and the Send button. The previous fix relied on visualViewport to shrink the list, but iPad PWAs don't reliably resize visualViewport when the keyboard opens (see the note in components/ui/dialog.tsx referencing the reverted #622–#624 global attempts). Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx): - Track search-input focus directly — the most reliable "keyboard is about to open" signal on touch devices, independent of visualViewport. - Gate the override behind `matchMedia('(pointer: coarse)')` so it only fires on touch-primary devices (iPad/iPhone/Android) and never on desktop or trackpad laptops — addressing the architect's cross-platform regression flag. - When the gate is active, override DialogContent's Radix center anchor (top:50%; translate(-50%,-50%)) with `top:6vh; translate(-50%,0); maxHeight:50dvh`. The dialog grows downward from a fixed top edge so it always fits in the upper half of the screen, leaving the lower half free for the keyboard. - Cap `listMaxHeight` to ~50% of innerHeight in the same gated condition, as a backstop where visualViewport stays full-height. - Debounce blur by 150ms so a tap on a recipient row doesn't lose the row mid-tap to a center-jump animation; clear the timeout on unmount. - Added 120ms ease transition on top/transform so the layout swap doesn't snap. Deviations: original plan used vv.offsetTop+vv.height/2 to reposition, but that depends on the same unreliable visualViewport. Switched to a focus + coarse-pointer driven approach which works on iPad PWA. Verification: tsc clean. Architect re-review pending after the touch gate was added (first review flagged the missing platform guard, which this revision addresses directly). --- .../src/components/notes/send-note-dialog.tsx | 120 ++++++++++++++++-- 1 file changed, 109 insertions(+), 11 deletions(-) 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"