From 3394754c18d36d0886464eee1f27f86c7982d537 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 18:12:22 -0400 Subject: [PATCH 1/4] fix(keyboard): share height state across useKeyboardHeight instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the thread drawer is open alongside the main room view, two RoomInput components each mount their own useKeyboardHeight instance. On keyboard open the thread instance had savedHeight=0 (freshly mounted), so its immediate-estimate branch fell back to viewport.height — the wrong mid-animation value — and overwrote the correct estimate already written by the main room instance. This produced a third layout change (wrong height → correct height) visible as jank on every keyboard open. Fix: promote savedHeight, cssVarsSet, and the mount reference counter to module-level variables so all instances share them. - sharedSavedHeight: all instances read and write the same value, so the estimate is always correct even for newly-mounted instances. - cssVarsApplied: the 'only set once while keyboard open' guard now works across instances, preventing double setCSSVars calls. - mountCount: reference-counted so only the last instance to unmount clears the CSS variables — prevents the thread drawer unmounting while the main room keyboard is still open from wiping --sable-visible-height. --- .../ios-keyboard-fix/useKeyboardHeight.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts diff --git a/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts new file mode 100644 index 000000000..b9016e5ba --- /dev/null +++ b/src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts @@ -0,0 +1,174 @@ +// Vendored from https://github.com/Crscristi28/ios-pwa-keyboard-fix (MIT) +// Replace this import path with 'ios-pwa-keyboard-fix' once published to npm. +import { useEffect, useRef, useState } from 'react'; + +// Measures iOS keyboard height via the Visual Viewport API and synchronously +// manages the --sable-visible-height / --sable-safe-bottom CSS custom properties +// that control #root layout height. +// +// CSS variables are set/cleared directly inside the event handler (no React +// useEffect) so there is no frame gap between "keyboard closed" being detected +// and the layout reverting to full height. This eliminates the race condition +// where a follow-on viewport.resize event would re-set the variable after the +// React async effect had already removed it, causing a persistent bottom gap. +// +// Stability filter — only commits React state (isKeyboardVisible, keyboardHeight) +// once iOS reports the same viewport height for STABILITY_MS ms. iOS emits +// chaotic transient values during keyboard transitions (text ↔ emoji), so the +// filter prevents those from triggering unnecessary re-renders. +// +// triggerPreLift: called from onMouseDown so Safari sees the textarea as already +// visible and skips its document-scroll prediction. +const STABILITY_MS = 80; + +// Module-level state shared across all useKeyboardHeight instances. +// The keyboard height is a device property — it's the same regardless of +// which input has focus. Sharing savedHeight prevents the case where two +// simultaneous RoomInput instances (main timeline + open thread drawer) race +// on keyboard open: the thread instance starts with savedHeight=0 and would +// overwrite the main instance's correct estimate with the wrong mid-animation +// viewport.height. +// mountCount is a reference counter so only the last unmounting instance +// clears the CSS vars (prevents the thread drawer unmounting mid-keyboard-open +// from wiping --sable-visible-height while the main room input still uses it). +let sharedSavedHeight = 0; +let mountCount = 0; +// Whether --sable-visible-height is currently applied. Shared so multiple +// instances see the same state and the "only set once while open" guard works +// across instances. +let cssVarsApplied = false; + +export function useKeyboardHeight() { + const [keyboardHeight, setKeyboardHeight] = useState(0); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + // Mirror state in refs so triggerPreLift sees fresh values from + // an onMouseDown handler without re-creating the function each render. + const hasOpenedOnce = useRef(false); + const isVisibleRef = useRef(false); + + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return undefined; + + mountCount += 1; + let baselineHeight = window.innerHeight; + let stabilityTimer: ReturnType | null = null; + let pendingValue = 0; + + const setCSSVars = (viewportHeight: number) => { + document.documentElement.style.setProperty( + '--sable-visible-height', + `${Math.round(viewportHeight)}px` + ); + document.documentElement.style.setProperty('--sable-safe-bottom', '0px'); + cssVarsApplied = true; + }; + + const clearCSSVars = () => { + document.documentElement.style.removeProperty('--sable-visible-height'); + document.documentElement.style.removeProperty('--sable-safe-bottom'); + cssVarsApplied = false; + }; + + const handleResize = () => { + const calculatedHeight = baselineHeight - viewport.height; + + // Keyboard closing — act immediately, no stability wait. + // clearCSSVars() runs synchronously here, before React schedules any + // re-render, so there is no window in which a follow-on resize event + // can observe the variable as missing and incorrectly re-set it. + if (calculatedHeight < 30) { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + isVisibleRef.current = false; + return; + } + + // Keyboard opening / open. + // On the very first resize that signals a keyboard, immediately shrink + // the layout (before the stability gate) so the input bar rises before + // iOS applies its own scroll-prediction pass. + // Use the previously-measured keyboard height as the estimate so the + // immediate and stability-timer setCSSVars calls land on the same pixel + // value — eliminating the second layout change that causes visible + // timeline stutter during the keyboard animation. + if (!cssVarsApplied) { + const estimatedViewportHeight = + sharedSavedHeight > 0 ? baselineHeight - sharedSavedHeight : viewport.height; + setCSSVars(estimatedViewportHeight); + } + + // Cancel any document scroll iOS may have applied as scroll-prediction. + if (window.scrollY !== 0) { + window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior }); + } + + // Wait for the height to settle. Each resize within STABILITY_MS + // restarts the timer, so transient mid-transition readings never + // commit — only the final settled value does. + pendingValue = calculatedHeight; + if (stabilityTimer) clearTimeout(stabilityTimer); + stabilityTimer = setTimeout(() => { + sharedSavedHeight = pendingValue; + hasOpenedOnce.current = true; + isVisibleRef.current = true; + setCSSVars(viewport.height); // refine to final settled viewport height + setKeyboardHeight(pendingValue); + setIsKeyboardVisible(true); + }, STABILITY_MS); + }; + + // Orientation change resets everything — keyboard heights measured + // in portrait don't apply in landscape and vice versa. Drop saved + // state and start fresh; the next focus will re-measure. + const handleOrientationChange = () => { + if (stabilityTimer) { + clearTimeout(stabilityTimer); + stabilityTimer = null; + } + pendingValue = 0; + sharedSavedHeight = 0; + hasOpenedOnce.current = false; + isVisibleRef.current = false; + clearCSSVars(); + setKeyboardHeight(0); + setIsKeyboardVisible(false); + // Re-baseline after iOS settles the new layout. + setTimeout(() => { + baselineHeight = window.innerHeight; + }, 200); + }; + + viewport.addEventListener('resize', handleResize); + window.addEventListener('orientationchange', handleOrientationChange); + return () => { + mountCount -= 1; + if (stabilityTimer) clearTimeout(stabilityTimer); + viewport.removeEventListener('resize', handleResize); + window.removeEventListener('orientationchange', handleOrientationChange); + // Only clear CSS vars when the last instance unmounts — prevents the thread + // drawer unmounting mid-keyboard-open from wiping the variable while the + // main room's RoomInput still has the keyboard open. + if (mountCount === 0) clearCSSVars(); + }; + }, []); + + // Pre-lift: called from onMouseDown, BEFORE focus event fires. + // Only acts if the keyboard is currently open — otherwise a button + // tap would lift the bar with no keyboard behind it. + // Reads from refs so it always sees the latest state, even when + // captured by an onMouseDown handler that mounted earlier. + const triggerPreLift = () => { + if (hasOpenedOnce.current && sharedSavedHeight > 0 && isVisibleRef.current) { + setKeyboardHeight(sharedSavedHeight); + } + }; + + return { keyboardHeight, isKeyboardVisible, triggerPreLift }; +} From 6b5cc6578cfe1266f627b6ae97a9760d97810c3f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 16:59:20 -0400 Subject: [PATCH 2/4] fix(mobile): prevent timeline stutter and false jump-to-present on keyboard open - useKeyboardHeight: use previously-measured height as the initial estimate so the immediate setCSSVars call and the stability-timer call land on the same pixel value. This eliminates the second layout pass that caused visible stutter during the iOS keyboard slide-up animation. - RoomTimeline ResizeObserver: record lastProgrammaticBottomPinAtRef before scrollTo when the viewport shrinks while at bottom. This keeps handleVListScroll inside the settle window so atBottom stays true and the 'Jump to Present' button no longer flashes when the keyboard opens. --- src/app/features/room/RoomTimeline.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..28b60975e 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -473,6 +473,10 @@ export function RoomTimeline({ const shrank = newHeight < prev; if (shrank && atBottom) { + // Record the programmatic pin so handleVListScroll sees withinSettleWindow=true + // and doesn't flip atBottom to false while VList commits the new scroll position. + // Without this, the "Jump to Present" button flashes every time the keyboard opens. + lastProgrammaticBottomPinAtRef.current = Date.now(); vListRef.current?.scrollTo(vListRef.current.scrollSize); } prevViewportHeightRef.current = newHeight; From 94e22210ba3c80a5bd50ad796ce345303bd51a3e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 19:41:40 -0400 Subject: [PATCH 3/4] fix(timeline): stay at bottom when keyboard opens/closes --- src/app/features/room/RoomTimeline.tsx | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 28b60975e..037947fd3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -190,6 +190,13 @@ export function RoomTimeline({ hideReadsRef.current = hideReads; const prevViewportHeightRef = useRef(0); + const prevScrollSizeRef = useRef(0); + // Tracks the VList-reported viewport size (as opposed to prevViewportHeightRef + // which tracks the DOM element height via ResizeObserver). Used in + // handleVListScroll to detect viewport shrink (keyboard opens) without a + // ResizeObserver race: when VList fires onScroll with a smaller viewportSize, + // we chase the bottom immediately instead of letting setAtBottom(false) fire. + const prevVListViewportRef = useRef(0); const messageListRef = useRef(null); const mediaAuthentication = useMediaAuthentication(); @@ -679,6 +686,51 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + const withinSettleWindow = + Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS; + + // When the user is pinned to the bottom and content grows (images, embeds, + // video thumbnails loading), scrollSize increases while offset stays put, + // pushing distanceFromBottom above the threshold. Instead of flipping + // atBottom to false (which shows the "Jump to Latest" button), chase the + // bottom so the user stays pinned. + const contentGrew = v.scrollSize > prevScrollSizeRef.current; + prevScrollSizeRef.current = v.scrollSize; + + // When the keyboard opens the VList viewport shrinks. The scrollOffset + // doesn't change, so distanceFromBottom jumps to ~keyboardHeight and + // isNowAtBottom becomes false — flashing the "Jump to Present" button. + // Detect the shrink here (inside onScroll, race-free) and chase the + // bottom before setAtBottom(false) is called. + const viewportShrank = + prevVListViewportRef.current > 0 && v.viewportSize < prevVListViewportRef.current; + prevVListViewportRef.current = v.viewportSize; + + // Skip content-chase and cache saves during init: the timeline is hidden + // (opacity 0) while VList measures items and fires intermediate scroll + // events. Chasing the bottom here causes cascading scrollTo calls that + // upstream doesn't have, producing visible layout churn after isReady. + if (!isReadyRef.current) return; + + // While a jump scroll is settling (briefly after scrollToIndex), VList + // fires intermediate scroll events that can incorrectly flip atBottom. + // Use a short-lived block instead of the full focusItem lifetime so that + // normal scrolling resumes quickly and atBottom is recomputed correctly. + if (jumpScrollBlockRef.current) return; + + if (atBottomRef.current && !isNowAtBottom && (contentGrew || viewportShrank || withinSettleWindow)) { + // Defer the chase to the next animation frame so VList finishes its + // current layout pass. Synchronous scrollTo causes cascading scroll + // events that produce visible jumps when images/embeds load. + requestAnimationFrame(() => { + const vl = vListRef.current; + if (vl && atBottomRef.current) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vl.scrollTo(vl.scrollSize); + } + }); + return; + } if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } From 47d2012a65616b62090dacfe50dd263a7bfc9e85 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 20:32:52 -0400 Subject: [PATCH 4/4] fix(timeline): chase bottom on any viewport size change (keyboard open/close) --- src/app/features/room/RoomTimeline.tsx | 27 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 037947fd3..6d8c5a7f6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -193,9 +193,10 @@ export function RoomTimeline({ const prevScrollSizeRef = useRef(0); // Tracks the VList-reported viewport size (as opposed to prevViewportHeightRef // which tracks the DOM element height via ResizeObserver). Used in - // handleVListScroll to detect viewport shrink (keyboard opens) without a - // ResizeObserver race: when VList fires onScroll with a smaller viewportSize, - // we chase the bottom immediately instead of letting setAtBottom(false) fire. + // handleVListScroll to detect viewport size changes (keyboard opens OR closes) + // without a ResizeObserver race: when VList fires onScroll with a different + // viewportSize, we chase the bottom immediately instead of letting + // setAtBottom(false) fire. const prevVListViewportRef = useRef(0); const messageListRef = useRef(null); @@ -697,13 +698,15 @@ export function RoomTimeline({ const contentGrew = v.scrollSize > prevScrollSizeRef.current; prevScrollSizeRef.current = v.scrollSize; - // When the keyboard opens the VList viewport shrinks. The scrollOffset - // doesn't change, so distanceFromBottom jumps to ~keyboardHeight and - // isNowAtBottom becomes false — flashing the "Jump to Present" button. - // Detect the shrink here (inside onScroll, race-free) and chase the + // When the keyboard opens/closes the VList viewportSize changes. The + // scrollOffset doesn't immediately follow, so distanceFromBottom spikes + // and isNowAtBottom becomes false — flashing the "Jump to Present" button. + // This is especially common when the keyboard opens/closes quickly before + // the chase RAF from a previous event has had a chance to execute. + // Detect the change here (inside onScroll, race-free) and chase the // bottom before setAtBottom(false) is called. - const viewportShrank = - prevVListViewportRef.current > 0 && v.viewportSize < prevVListViewportRef.current; + const viewportChanged = + prevVListViewportRef.current > 0 && v.viewportSize !== prevVListViewportRef.current; prevVListViewportRef.current = v.viewportSize; // Skip content-chase and cache saves during init: the timeline is hidden @@ -718,7 +721,11 @@ export function RoomTimeline({ // normal scrolling resumes quickly and atBottom is recomputed correctly. if (jumpScrollBlockRef.current) return; - if (atBottomRef.current && !isNowAtBottom && (contentGrew || viewportShrank || withinSettleWindow)) { + if ( + atBottomRef.current && + !isNowAtBottom && + (contentGrew || viewportChanged || withinSettleWindow) + ) { // Defer the chase to the next animation frame so VList finishes its // current layout pass. Synchronous scrollTo causes cascading scroll // events that produce visible jumps when images/embeds load.