| @@ -55,14 +55,7 @@ import { FilePreviewModal, type FilePreviewSource } from "../../components/FileP · 54 unmodified lines |
| 55 | 55 | import { isPdfFile } from "../../lib/filePreview"; |
| 56 | 56 | import { PresentationSource } from "../../components/NativePresentation"; |
| 57 | 57 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 58 | | −import Animated, { |
| 59 | | − FadeIn, |
| 60 | | − FadeInUp, |
| 61 | | − useSharedValue, |
| 62 | | − withTiming, |
| 63 | | − type LayoutAnimationsValues, |
| 64 | | − type SharedValue, |
| 65 | | −} from "react-native-reanimated"; |
| 58 | +import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; |
| 66 | 59 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 67 | 60 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 68 | 61 | import { useFontFamily } from "../../lib/useFontFamily"; |
| @@ -167,14 +160,6 @@ function formatMessageTime(input: string): string { · 98 unmodified lines |
| 167 | 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 | 163 | // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's |
| 179 | 164 | // classNames. The fold row's min-h-11 (44px) stays taller than its single |
| 180 | 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 | 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 | 189 | export interface ThreadFeedProps { |
| 198 | 190 | readonly environmentId: EnvironmentId; |
| 199 | 191 | readonly threadId: ThreadId; |
| @@ -210,6 +202,7 @@ export interface ThreadFeedProps { · 10 unmodified lines |
| 210 | 202 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 211 | 203 | readonly contentTopInset?: number; |
| 212 | 204 | readonly contentBottomInset?: number; |
| 205 | + readonly historyControls?: ThreadFeedHistoryControls; |
| 213 | 206 | readonly topAccessory?: ReactNode; |
| 214 | 207 | readonly contentMaxWidth?: number; |
| 215 | 208 | readonly layoutVariant?: LayoutVariant; |
| @@ -1642,6 +1635,41 @@ function compactFileName(filePath: string): string { · 1426 unmodified lines |
| 1642 | 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 | 1673 | function ThreadFeedPlaceholder(props: { |
| 1646 | 1674 | readonly bottomInset: number; |
| 1647 | 1675 | readonly detail: string; |
| @@ -1891,11 +1919,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 243 unmodified lines |
| 1891 | 1919 | }, |
| 1892 | 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 | 1922 | const handleScroll = useCallback( |
| 1900 | 1923 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 1901 | 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 | 1926 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1904 | 1927 | // header height back or the material toggles a full header too late. |
| 1905 | 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 | 1929 | // LegendList recomputes its inset-aware end distance before invoking |
| 1910 | 1930 | // this handler, so getState() is current. Only the actual end re-arms |
| 1911 | 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 | | − [ |
| 1925 | | − reportHeaderMaterialVisibility, |
| 1926 | | − anchorTopInset, |
| 1927 | | − nearListEnd, |
| 1928 | | − props.listRef, |
| 1929 | | − transitionEndFollow, |
| 1930 | | − ], |
| 1944 | + [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], |
| 1931 | 1945 | ); |
| 1932 | 1946 | const clearUserScrollSettle = useCallback(() => { |
| 1933 | 1947 | if (userScrollSettleTimerRef.current !== null) { |
| @@ -1999,33 +2013,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 65 unmodified lines |
| 1999 | 2013 | } |
| 2000 | 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 | 2016 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 2030 | 2017 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| 2031 | 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 | 2366 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 2380 | 2367 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 2381 | | − itemLayoutAnimation={feedItemLayoutTransition} |
| 2382 | 2368 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): |
| 2383 | 2369 | // lets its scroll math clamp programmatic scrolls to -headerInset |
| 2384 | 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 | 2446 | ListHeaderComponent={ |
| 2461 | 2447 | <> |
| 2462 | 2448 | {usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />} |
| 2449 | + {props.historyControls ? ( |
| 2450 | + <ThreadFeedLoadEarlierControl {...props.historyControls} /> |
| 2451 | + ) : null} |
| 2463 | 2452 | {props.topAccessory} |
| 2464 | 2453 | </> |
| 2465 | 2454 | } |