| @@ -55,14 +55,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP · 54 unmodified lines |
| 55 | import { isPdfFile } from "../../lib/filePreview"; | 55 | import { isPdfFile } from "../../lib/filePreview"; |
| 56 | import { PresentationSource } from "../../components/NativePresentation"; | 56 | import { PresentationSource } from "../../components/NativePresentation"; |
| 57 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 57 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 58 | import Animated, { | 58 | import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; |
| 59 | FadeIn, | | |
| 60 | FadeInUp, | | |
| 61 | useSharedValue, | | |
| 62 | withTiming, | | |
| 63 | type LayoutAnimationsValues, | | |
| 64 | type SharedValue, | | |
| 65 | } from "react-native-reanimated"; | | |
| 66 | import { useThemeColor } from "../../lib/useThemeColor"; | 59 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 67 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; | 60 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 68 | import { useFontFamily } from "../../lib/useFontFamily"; | 61 | import { useFontFamily } from "../../lib/useFontFamily"; |
| @@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines |
| 167 | return MESSAGE_TIME_FORMATTER.format(timestamp); | 160 | return MESSAGE_TIME_FORMATTER.format(timestamp); |
| 168 | } | 161 | } |
| 169 | | 162 | |
| 170 | // Rows shift when content above them grows (streaming text, work-log folds); | | |
| 171 | // animating the container position turns those jumps into slides. Applied | | |
| 172 | // conditionally — see the gated transition in ThreadFeed: while browsing | | |
| 173 | // history the animation must NOT run, or every estimate→actual size | | |
| 174 | // correction plays as a visible slide against the instant scroll-offset | | |
| 175 | // compensation from maintainVisibleContentPosition. | | |
| 176 | const FEED_ITEM_LAYOUT_DURATION_MS = 180; | | |
| 177 | | | |
| 178 | // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's | 163 | // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's |
| 179 | // classNames. The fold row's min-h-11 (44px) stays taller than its single | 164 | // classNames. The fold row's min-h-11 (44px) stays taller than its single |
| 180 | // text-sm line at every supported base font size (26px at the 22pt maximum), | 165 | // text-sm line at every supported base font size (26px at the 22pt maximum), |
| @@ -194,6 +179,13 @@ function isFreshTimestamp(input: string): boolean { · 13 unmodified lines |
| 194 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; | 179 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; |
| 195 | } | 180 | } |
| 196 | | 181 | |
| | 182 | export interface ThreadFeedHistoryControls { |
| | 183 | readonly hasMoreHistory: boolean; |
| | 184 | readonly loading: boolean; |
| | 185 | readonly error: string | null; |
| | 186 | readonly onLoadEarlier: () => void; |
| | 187 | } |
| | 188 | |
| 197 | export interface ThreadFeedProps { | 189 | export interface ThreadFeedProps { |
| 198 | readonly environmentId: EnvironmentId; | 190 | readonly environmentId: EnvironmentId; |
| 199 | readonly threadId: ThreadId; | 191 | readonly threadId: ThreadId; |
| @@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines |
| 210 | readonly contentInsetEndAdjustment: SharedValue<number>; | 202 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 211 | readonly contentTopInset?: number; | 203 | readonly contentTopInset?: number; |
| 212 | readonly contentBottomInset?: number; | 204 | readonly contentBottomInset?: number; |
| | 205 | readonly historyControls?: ThreadFeedHistoryControls; |
| 213 | readonly topAccessory?: ReactNode; | 206 | readonly topAccessory?: ReactNode; |
| 214 | readonly contentMaxWidth?: number; | 207 | readonly contentMaxWidth?: number; |
| 215 | readonly layoutVariant?: LayoutVariant; | 208 | readonly layoutVariant?: LayoutVariant; |
| @@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines |
| 1642 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; | 1635 | return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; |
| 1643 | } | 1636 | } |
| 1644 | | 1637 | |
| | 1638 | function ThreadFeedLoadEarlierControl(props: ThreadFeedHistoryControls) { |
| | 1639 | const mutedColor = useThemeColor("--color-icon-subtle"); |
| | 1640 | const accentColor = useThemeColor("--color-primary"); |
| | 1641 | if (!props.hasMoreHistory && props.error === null) { |
| | 1642 | return null; |
| | 1643 | } |
| | 1644 | return ( |
| | 1645 | <View className="mb-3 items-center gap-1.5 px-2"> |
| | 1646 | {props.hasMoreHistory ? ( |
| | 1647 | <Pressable |
| | 1648 | accessibilityRole="button" |
| | 1649 | accessibilityLabel="Load earlier activity" |
| | 1650 | disabled={props.loading} |
| | 1651 | onPress={props.onLoadEarlier} |
| | 1652 | className="min-h-9 flex-row items-center justify-center gap-2 rounded-full border border-border/60 bg-surface/80 px-4 py-2 disabled:opacity-50" |
| | 1653 | > |
| | 1654 | {props.loading ? ( |
| | 1655 | <ActivityIndicator size="small" color={accentColor} /> |
| | 1656 | ) : ( |
| | 1657 | <SymbolView name="chevron.up" size={12} tintColor={accentColor} type="monochrome" /> |
| | 1658 | )} |
| | 1659 | <Text className="text-sm font-medium text-foreground"> |
| | 1660 | {props.loading ? "Loading earlier activity…" : "Load earlier activity"} |
| | 1661 | </Text> |
| | 1662 | </Pressable> |
| | 1663 | ) : null} |
| | 1664 | {props.error !== null ? ( |
| | 1665 | <Text className="text-center text-xs" style={{ color: mutedColor }}> |
| | 1666 | {props.error} |
| | 1667 | </Text> |
| | 1668 | ) : null} |
| | 1669 | </View> |
| | 1670 | ); |
| | 1671 | } |
| | 1672 | |
| 1645 | function ThreadFeedPlaceholder(props: { | 1673 | function ThreadFeedPlaceholder(props: { |
| 1646 | readonly bottomInset: number; | 1674 | readonly bottomInset: number; |
| 1647 | readonly detail: string; | 1675 | readonly detail: string; |
| @@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines |
| 1891 | }, | 1919 | }, |
| 1892 | [props.onHeaderMaterialVisibilityChange], | 1920 | [props.onHeaderMaterialVisibilityChange], |
| 1893 | ); | 1921 | ); |
| 1894 | // True while the viewport sits within ~one screen of the list end — the | | |
| 1895 | // only region where layout shifts should animate. Starts true because the | | |
| 1896 | // list opens pinned to the end. | | |
| 1897 | const nearListEnd = useSharedValue(true); | | |
| 1898 | | | |
| 1899 | const handleScroll = useCallback( | 1922 | const handleScroll = useCallback( |
| 1900 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | 1923 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 1901 | // anchorTopInset, not topContentInset: under automatic insets the list | 1924 | // anchorTopInset, not topContentInset: under automatic insets the list |
| @@ -1903,9 +1926,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 1 unmodified line |
| 1903 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the | 1926 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1904 | // header height back or the material toggles a full header too late. | 1927 | // header height back or the material toggles a full header too late. |
| 1905 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); | 1928 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); |
| 1906 | const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; | | |
| 1907 | nearListEnd.value = | | |
| 1908 | contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; | | |
| 1909 | // LegendList recomputes its inset-aware end distance before invoking | 1929 | // LegendList recomputes its inset-aware end distance before invoking |
| 1910 | // this handler, so getState() is current. Only the actual end re-arms | 1930 | // this handler, so getState() is current. Only the actual end re-arms |
| 1911 | // follow: its broader maintain-scroll threshold is large enough for a | 1931 | // follow: its broader maintain-scroll threshold is large enough for a |
| @@ -1921,13 +1941,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 9 unmodified lines |
| 1921 | }); | 1941 | }); |
| 1922 | } | 1942 | } |
| 1923 | }, | 1943 | }, |
| 1924 | [ | 1944 | [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], |
| 1925 | reportHeaderMaterialVisibility, | | |
| 1926 | anchorTopInset, | | |
| 1927 | nearListEnd, | | |
| 1928 | props.listRef, | | |
| 1929 | transitionEndFollow, | | |
| 1930 | ], | | |
| 1931 | ); | 1945 | ); |
| 1932 | const clearUserScrollSettle = useCallback(() => { | 1946 | const clearUserScrollSettle = useCallback(() => { |
| 1933 | if (userScrollSettleTimerRef.current !== null) { | 1947 | if (userScrollSettleTimerRef.current !== null) { |
| @@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines |
| 1999 | } | 2013 | } |
| 2000 | }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); | 2014 | }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); |
| 2001 | | 2015 | |
| 2002 | // Gated variant of the 180ms feed layout slide. Instant while browsing | | |
| 2003 | // history: maintainVisibleContentPosition compensates the scroll offset in | | |
| 2004 | // the same frame a row's measured size lands, so an instant reposition is | | |
| 2005 | // invisible — animating it is exactly what made cold upward scrolls slide | | |
| 2006 | // and jump. Near the end the slide stays on: streaming growth and sends | | |
| 2007 | // shift rows at rest, where the animation is the thing preventing a hard | | |
| 2008 | // visual snap. | | |
| 2009 | const feedItemLayoutTransition = useMemo(() => { | | |
| 2010 | return (values: LayoutAnimationsValues) => { | | |
| 2011 | "worklet"; | | |
| 2012 | const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0; | | |
| 2013 | return { | | |
| 2014 | initialValues: { | | |
| 2015 | originX: values.currentOriginX, | | |
| 2016 | originY: values.currentOriginY, | | |
| 2017 | width: values.currentWidth, | | |
| 2018 | height: values.currentHeight, | | |
| 2019 | }, | | |
| 2020 | animations: { | | |
| 2021 | originX: withTiming(values.targetOriginX, { duration }), | | |
| 2022 | originY: withTiming(values.targetOriginY, { duration }), | | |
| 2023 | width: withTiming(values.targetWidth, { duration }), | | |
| 2024 | height: withTiming(values.targetHeight, { duration }), | | |
| 2025 | }, | | |
| 2026 | }; | | |
| 2027 | }; | | |
| 2028 | }, [nearListEnd]); | | |
| 2029 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { | 2016 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 2030 | const nextWidth = Math.round(event.nativeEvent.layout.width); | 2017 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| 2031 | const nextHeight = Math.round(event.nativeEvent.layout.height); | 2018 | const nextHeight = Math.round(event.nativeEvent.layout.height); |
| @@ -2378,7 +2365,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 346 unmodified lines |
| 2378 | } | 2365 | } |
| 2379 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} | 2366 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 2380 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | 2367 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 2381 | itemLayoutAnimation={feedItemLayoutTransition} | | |
| 2382 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): | 2368 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): |
| 2383 | // lets its scroll math clamp programmatic scrolls to -headerInset | 2369 | // lets its scroll math clamp programmatic scrolls to -headerInset |
| 2384 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short | 2370 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short |
| @@ -2460,6 +2446,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 75 unmodified lines |
| 2460 | ListHeaderComponent={ | 2446 | ListHeaderComponent={ |
| 2461 | <> | 2447 | <> |
| 2462 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} | 2448 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} |
| | 2449 | {props.historyControls ? ( |
| | 2450 | <ThreadFeedLoadEarlierControl {...props.historyControls} /> |
| | 2451 | ) : null} |
| 2463 | {props.topAccessory} | 2452 | {props.topAccessory} |
| 2464 | </> | 2453 | </> |
| 2465 | } | 2454 | } |