From 2cc955ed65cdf757f414f6c10d17961654f40be2 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sun, 24 May 2026 09:48:10 +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, tapping the search field opened the soft keyboard which covered the recipients list and the Send button — the dialog stayed centered on the full layout viewport. Fix (artifacts/tx-os/src/components/notes/send-note-dialog.tsx): Two-tier keyboard detection: 1. Primary — `vv.keyboardInset > 0` from useVisualViewport. Works on desktop touch laptops, iPad Safari (regular tab), Android Chrome. When true, override DialogContent style with top = vv.offsetTop + vv.height/2 − 48 (QuickType inset) transform: translate(-50%, -50%) so the dialog recenters around the visible viewport region. 2. Fallback — `searchFocused && matchMedia('(pointer: coarse)')`. Only used when visualViewport does NOT report an inset, which is the iPad PWA (Add to Home Screen) case explicitly called out in components/ui/dialog.tsx comment. Top-anchors at 6vh with maxHeight 50dvh so the dialog never crosses the screen midpoint. 3. Neither — pass NO style at all so Radix's default centered layout is fully restored (no leftover transition or top). Touch-only fallback gating (matchMedia pointer:coarse) prevents the earlier desktop-regression flagged by code review — focusing the search on a desktop never repositions the dialog. Other changes: - onFocus/onBlur on the search input feed the fallback signal. - Blur is debounced 150ms so tapping a recipient row mid-blur doesn't recenter the dialog out from under the finger. - useEffect cleanup clears pending blur timeout on unmount. Verification: tsc clean. Architect approved the touch-gated fallback; follow-up review reconciled the diff with the spec's primary visualViewport-driven path. --- .../src/components/notes/send-note-dialog.tsx | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 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 e4fef70f..9738c0ae 100644 --- a/artifacts/tx-os/src/components/notes/send-note-dialog.tsx +++ b/artifacts/tx-os/src/components/notes/send-note-dialog.tsx @@ -120,14 +120,22 @@ export function SendNoteDialog({ }, 150); }; + // Primary signal: visualViewport actually reports a keyboard inset. + // Used on desktop touch laptops, iPad Safari (regular tab), and + // Android Chrome where vv updates correctly. + const keyboardActive = vv.keyboardInset > 0; + // Fallback signal: iPad PWA (Add to Home Screen) doesn't reliably + // update visualViewport when the keyboard opens (see comment in + // components/ui/dialog.tsx). On touch-primary devices, treat focus + // on the search input as evidence the keyboard is up so the dialog + // still gets repositioned. This is a fallback only — when vv DOES + // update, the primary branch above takes over. + const keyboardFallback = !keyboardActive && searchFocused && isTouchPrimary; const KEYBOARD_PREDICTION_INSET = - vv.keyboardInset > 0 || keyboardLikely ? 48 : 0; + keyboardActive || keyboardFallback ? 48 : 0; const listMaxHeight = (() => { - // 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 + const assumedUsable = keyboardFallback ? Math.min(baseHeight, window.innerHeight * 0.5) : baseHeight; const usable = assumedUsable - KEYBOARD_PREDICTION_INSET; @@ -139,20 +147,32 @@ 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 + // Dialog positioning override: + // • Primary path (`keyboardActive`): compute the new vertical + // centre from the *visual* viewport — anchored at + // `offsetTop + height/2`, shifted up by the QuickType strip + // allowance so the Send button isn't hidden under predictions. + // Keep Radix's `translate(-50%, -50%)` so the dialog stays + // centered around the new anchor. + // • Fallback path (`keyboardFallback`): top-anchor at 6vh with + // `translate(-50%, 0)` and cap `maxHeight: 50dvh` so the dialog + // never crosses the screen midpoint where the keyboard sits. + // • Otherwise: pass NO style so Radix's default centered layout + // is fully restored (no leftover transition, no leftover top). + const dialogStyle: React.CSSProperties | undefined = keyboardActive ? { - top: "6vh", - transform: "translate(-50%, 0)", - maxHeight: "50dvh", - transition: "top 120ms ease, transform 120ms ease", + top: vv.offsetTop + vv.height / 2 - KEYBOARD_PREDICTION_INSET, + transform: "translate(-50%, -50%)", + transition: "top 120ms ease", } - : { transition: "top 120ms ease, transform 120ms ease" }; + : keyboardFallback + ? { + top: "6vh", + transform: "translate(-50%, 0)", + maxHeight: "50dvh", + transition: "top 120ms ease, transform 120ms ease", + } + : undefined; const candidates = useMemo(() => { const me = user?.id; @@ -217,7 +237,7 @@ export function SendNoteDialog({ !o && onClose()}>