Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@
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 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<HTMLDivElement>(null);

const mediaAuthentication = useMediaAuthentication();
Expand Down Expand Up @@ -473,6 +481,10 @@
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();

Check failure on line 487 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'lastProgrammaticBottomPinAtRef'.
vListRef.current?.scrollTo(vListRef.current.scrollSize);
}
prevViewportHeightRef.current = newHeight;
Expand Down Expand Up @@ -675,6 +687,57 @@

const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
const isNowAtBottom = distanceFromBottom < 100;
const withinSettleWindow =
Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS;

Check failure on line 691 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'SCROLL_SETTLE_MS'.

Check failure on line 691 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'lastProgrammaticBottomPinAtRef'.

// 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/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 viewportChanged =
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;

Check failure on line 716 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'isReadyRef'. Did you mean 'isReady'?

// 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;

Check failure on line 722 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'jumpScrollBlockRef'.

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.
requestAnimationFrame(() => {
const vl = vListRef.current;
if (vl && atBottomRef.current) {
lastProgrammaticBottomPinAtRef.current = Date.now();

Check failure on line 735 in src/app/features/room/RoomTimeline.tsx

View workflow job for this annotation

GitHub Actions / Typecheck

Cannot find name 'lastProgrammaticBottomPinAtRef'.
vl.scrollTo(vl.scrollSize);
}
});
return;
}
if (isNowAtBottom !== atBottomRef.current) {
setAtBottom(isNowAtBottom);
}
Expand Down
174 changes: 174 additions & 0 deletions src/app/hooks/ios-keyboard-fix/useKeyboardHeight.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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 };
}
Loading