authorgravatar for github@jayz.usJay Meistrich <github@jayz.us> 2026-08-07 10:40:06+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 09:40:06+00:00
log8100062a78f1e1942663a0cea9ddc42d2f835525
treee3eda4dba1029bd5ac67fba0ec9d69e7568b01fc
parenta1762fdd7482728800f1f4fd260c632f01a30b1f
signature Signed by PGP key B5690EEEBB952194

fix(mobile): improve keyboard avoiding (#5451)


2 files changed, 7 insertions(+), 54 deletions(-)

apps/mobile/package.json+5
...@@ -138,5 +138,10 @@...@@ -138,5 +138,10 @@
138 "@react-native-menu/menu"138 "@react-native-menu/menu"
139 ]139 ]
140 }140 }
141 },
142 "reanimated": {
143 "staticFeatureFlags": {
144 "DISABLE_COMMIT_PAUSING_MECHANISM": true
145 }
141 }146 }
142}147}
apps/mobile/src/features/threads/ThreadFeed.tsx+2-54
...@@ -45,14 +45,7 @@ import {...@@ -45,14 +45,7 @@ import {
45import { TouchableOpacity } from "react-native-gesture-handler";45import { TouchableOpacity } from "react-native-gesture-handler";
46import ImageViewing from "react-native-image-viewing";46import ImageViewing from "react-native-image-viewing";
47import { useSafeAreaInsets } from "react-native-safe-area-context";47import { useSafeAreaInsets } from "react-native-safe-area-context";
48import Animated, {48import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
49 FadeIn,
50 FadeInUp,
51 useSharedValue,
52 withTiming,
53 type LayoutAnimationsValues,
54 type SharedValue,
55} from "react-native-reanimated";
56import { useThemeColor } from "../../lib/useThemeColor";49import { useThemeColor } from "../../lib/useThemeColor";
57import { useFontFamily } from "../../lib/useFontFamily";50import { useFontFamily } from "../../lib/useFontFamily";
58import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";51import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
...@@ -117,14 +110,6 @@ function formatMessageTime(input: string): string {...@@ -117,14 +110,6 @@ function formatMessageTime(input: string): string {
117 return MESSAGE_TIME_FORMATTER.format(timestamp);110 return MESSAGE_TIME_FORMATTER.format(timestamp);
118}111}
119112
120// Rows shift when content above them grows (streaming text, work-log folds);
121// animating the container position turns those jumps into slides. Applied
122// conditionally — see the gated transition in ThreadFeed: while browsing
123// history the animation must NOT run, or every estimate→actual size
124// correction plays as a visible slide against the instant scroll-offset
125// compensation from maintainVisibleContentPosition.
126const FEED_ITEM_LAYOUT_DURATION_MS = 180;
127
128// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's113// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
129// classNames. The fold row's min-h-11 (44px) stays taller than its single114// classNames. The fold row's min-h-11 (44px) stays taller than its single
130// text-sm line at every supported base font size (26px at the 22pt maximum),115// text-sm line at every supported base font size (26px at the 22pt maximum),
...@@ -1457,11 +1442,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1457,11 +1442,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1457 },1442 },
1458 [props.onHeaderMaterialVisibilityChange],1443 [props.onHeaderMaterialVisibilityChange],
1459 );1444 );
1460 // True while the viewport sits within ~one screen of the list end — the
1461 // only region where layout shifts should animate. Starts true because the
1462 // list opens pinned to the end.
1463 const nearListEnd = useSharedValue(true);
1464
1465 const handleScroll = useCallback(1445 const handleScroll = useCallback(
1466 (event: NativeSyntheticEvent<NativeScrollEvent>) => {1446 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
1467 // anchorTopInset, not topContentInset: under automatic insets the list1447 // anchorTopInset, not topContentInset: under automatic insets the list
...@@ -1469,10 +1449,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1469,10 +1449,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1469 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the1449 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
1470 // header height back or the material toggles a full header too late.1450 // header height back or the material toggles a full header too late.
1471 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);1451 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1472 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
1473 nearListEnd.value =
1474 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
1475
1476 // Latch bookkeeping. LegendList recomputes its inset-aware end distance1452 // Latch bookkeeping. LegendList recomputes its inset-aware end distance
1477 // before invoking this handler, so getState() is current. Returning to1453 // before invoking this handler, so getState() is current. Returning to
1478 // the end re-arms follow no matter who scrolled (the user, or our own1454 // the end re-arms follow no matter who scrolled (the user, or our own
...@@ -1488,7 +1464,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1488,7 +1464,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1488 }1464 }
1489 }1465 }
1490 },1466 },
1491 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow],1467 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow],
1492 );1468 );
1493 const handleScrollBeginDrag = useCallback(() => {1469 const handleScrollBeginDrag = useCallback(() => {
1494 userScrollSessionRef.current = true;1470 userScrollSessionRef.current = true;
...@@ -1508,33 +1484,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1508,33 +1484,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1508 userScrollSessionRef.current = false;1484 userScrollSessionRef.current = false;
1509 }, []);1485 }, []);
15101486
1511 // Gated variant of the 180ms feed layout slide. Instant while browsing
1512 // history: maintainVisibleContentPosition compensates the scroll offset in
1513 // the same frame a row's measured size lands, so an instant reposition is
1514 // invisible — animating it is exactly what made cold upward scrolls slide
1515 // and jump. Near the end the slide stays on: streaming growth and sends
1516 // shift rows at rest, where the animation is the thing preventing a hard
1517 // visual snap.
1518 const feedItemLayoutTransition = useMemo(() => {
1519 return (values: LayoutAnimationsValues) => {
1520 "worklet";
1521 const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0;
1522 return {
1523 initialValues: {
1524 originX: values.currentOriginX,
1525 originY: values.currentOriginY,
1526 width: values.currentWidth,
1527 height: values.currentHeight,
1528 },
1529 animations: {
1530 originX: withTiming(values.targetOriginX, { duration }),
1531 originY: withTiming(values.targetOriginY, { duration }),
1532 width: withTiming(values.targetWidth, { duration }),
1533 height: withTiming(values.targetHeight, { duration }),
1534 },
1535 };
1536 };
1537 }, [nearListEnd]);
1538 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {1487 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
1539 const nextWidth = Math.round(event.nativeEvent.layout.width);1488 const nextWidth = Math.round(event.nativeEvent.layout.width);
1540 const nextHeight = Math.round(event.nativeEvent.layout.height);1489 const nextHeight = Math.round(event.nativeEvent.layout.height);
...@@ -1886,7 +1835,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1886,7 +1835,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1886 }1835 }
1887 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}1836 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
1888 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1837 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1889 itemLayoutAnimation={feedItemLayoutTransition}
1890 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):1838 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
1891 // lets its scroll math clamp programmatic scrolls to -headerInset1839 // lets its scroll math clamp programmatic scrolls to -headerInset
1892 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short1840 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short