From e40c9bcf980ecb36e5e626465dd794cc865d57fd Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 18 May 2026 18:47:41 +0100 Subject: [PATCH] [Chat] Persist left column scroll in splitview (#10513) --- src/screens/Messages/ChatList.tsx | 26 +++++++++++++++++-- .../splitView/MessagesSplitViewLayout.tsx | 19 +++++++++++--- .../components/splitView/leftColumnScroll.ts | 4 +++ 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 src/screens/Messages/components/splitView/leftColumnScroll.ts diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx index dbc669c3c8..69364f3fbc 100644 --- a/src/screens/Messages/ChatList.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {View} from 'react-native' import {useAnimatedRef} from 'react-native-reanimated' import {type ChatBskyConvoDefs} from '@atproto/api' @@ -45,6 +45,7 @@ import {IS_NATIVE} from '#/env' import {ChatListItem} from './components/ChatListItem' import {InboxRequests} from './components/InboxRequests' import {useIsWithinSplitView} from './components/splitView/context' +import {splitViewLeftScroll} from './components/splitView/leftColumnScroll' type ListItem = { type: 'CONVERSATION' @@ -255,12 +256,32 @@ export function ChatList({ animated: IS_NATIVE, offset: 0, }) + if (isWithinSplitView) { + splitViewLeftScroll.current = 0 + restoredRef.current = true + } try { await refetch() } catch (err) { logger.error('Failed to refresh conversations', {message: err}) } - }, [scrollElRef, refetch]) + }, [scrollElRef, refetch, isWithinSplitView]) + + // Restore the saved scroll offset once the list has rendered enough + // content to honor it. Module-level ref survives ChatList re-mounts that + // happen on in-splitview navigation (see leftColumnScroll.ts). + const restoredRef = useRef(false) + const onContentSizeChange = useCallback( + (_w: number, h: number) => { + if (!isWithinSplitView || restoredRef.current) return + const offset = splitViewLeftScroll.current + if (offset > 0 && h >= offset) { + scrollElRef.current?.scrollToOffset({offset, animated: false}) + restoredRef.current = true + } + }, + [isWithinSplitView, scrollElRef], + ) const isScreenFocused = useIsFocused() useEffect(() => { @@ -364,6 +385,7 @@ export function ChatList({ /> } onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} + onContentSizeChange={onContentSizeChange} initialNumToRender={initialNumToRender} windowSize={11} desktopFixedHeight diff --git a/src/screens/Messages/components/splitView/MessagesSplitViewLayout.tsx b/src/screens/Messages/components/splitView/MessagesSplitViewLayout.tsx index 00e57b4e6a..91428a08eb 100644 --- a/src/screens/Messages/components/splitView/MessagesSplitViewLayout.tsx +++ b/src/screens/Messages/components/splitView/MessagesSplitViewLayout.tsx @@ -1,8 +1,11 @@ +import {useCallback} from 'react' import {View} from 'react-native' +import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' import {type ScreenLayoutArgs, useIsFocused} from '@react-navigation/native' import {type NativeStackNavigationProp} from '@react-navigation/native-stack' import {type FlatNavigatorParams} from '#/lib/routes/types' +import {ScrollProvider} from '#/lib/ScrollContext' import {type NativeStackNavigationOptionsWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {atoms as a, useLayoutBreakpoints, useTheme, web} from '#/alf' import {useDialogControl} from '#/components/Dialog' @@ -13,6 +16,7 @@ import {useAgeAssurance} from '#/ageAssurance' import {IS_WEB} from '#/env' import {ChatList, Header as ChatListHeader} from '../../ChatList' import {SplitViewProvider} from './context' +import {splitViewLeftScroll} from './leftColumnScroll' const CENTER_COLUMN_WIDTH = 600 const LEFT_NAV_FULL_WIDTH = 245 @@ -48,6 +52,11 @@ function MessagesSplitViewLayout({children, navigation, route}: LayoutProps) { const aa = useAgeAssurance() const isFocused = useIsFocused() + const onLeftColumnScroll = useCallback((e: ReanimatedScrollEvent) => { + 'worklet' + splitViewLeftScroll.current = e.contentOffset.y + }, []) + if (!IS_WEB || !rightNavVisible || aa.state.access !== aa.Access.Full) { return children } @@ -106,10 +115,12 @@ function MessagesSplitViewLayout({children, navigation, route}: LayoutProps) { {width: containerWidth - centerColumnWidth}, ]}> - + + + diff --git a/src/screens/Messages/components/splitView/leftColumnScroll.ts b/src/screens/Messages/components/splitView/leftColumnScroll.ts new file mode 100644 index 0000000000..ca56c0e98b --- /dev/null +++ b/src/screens/Messages/components/splitView/leftColumnScroll.ts @@ -0,0 +1,4 @@ +// Holds the splitview left column's scroll offset across re-mounts caused +// by in-splitview navigation. Reset on full page reload, mirroring the +// in-memory semantics of useWebScrollRestoration. +export const splitViewLeftScroll = {current: 0}