Keep pager feeds in sync with the right pane (#2775)
* Hoist selected feed state * Seed state from params * Refine and fix logic * Fix scroll restoration * Soft reset on second click
This commit is contained in:
		
							parent
							
								
									80c482b026
								
							
						
					
					
						commit
						06f81d6948
					
				
					 6 changed files with 148 additions and 63 deletions
				
			
		|  | @ -32,6 +32,7 @@ import {Provider as MutedThreadsProvider} from 'state/muted-threads' | |||
| import {Provider as InvitesStateProvider} from 'state/invites' | ||||
| import {Provider as PrefsStateProvider} from 'state/preferences' | ||||
| import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' | ||||
| import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' | ||||
| import I18nProvider from './locale/i18nProvider' | ||||
| import { | ||||
|   Provider as SessionProvider, | ||||
|  | @ -72,17 +73,19 @@ function InnerApp() { | |||
|             // Resets the entire tree below when it changes:
 | ||||
|             key={currentAccount?.did}> | ||||
|             <LoggedOutViewProvider> | ||||
|               <UnreadNotifsProvider> | ||||
|                 <ThemeProvider theme={theme}> | ||||
|                   {/* All components should be within this provider */} | ||||
|                   <RootSiblingParent> | ||||
|                     <GestureHandlerRootView style={s.h100pct}> | ||||
|                       <TestCtrls /> | ||||
|                       <Shell /> | ||||
|                     </GestureHandlerRootView> | ||||
|                   </RootSiblingParent> | ||||
|                 </ThemeProvider> | ||||
|               </UnreadNotifsProvider> | ||||
|               <SelectedFeedProvider> | ||||
|                 <UnreadNotifsProvider> | ||||
|                   <ThemeProvider theme={theme}> | ||||
|                     {/* All components should be within this provider */} | ||||
|                     <RootSiblingParent> | ||||
|                       <GestureHandlerRootView style={s.h100pct}> | ||||
|                         <TestCtrls /> | ||||
|                         <Shell /> | ||||
|                       </GestureHandlerRootView> | ||||
|                     </RootSiblingParent> | ||||
|                   </ThemeProvider> | ||||
|                 </UnreadNotifsProvider> | ||||
|               </SelectedFeedProvider> | ||||
|             </LoggedOutViewProvider> | ||||
|           </React.Fragment> | ||||
|         </Splash> | ||||
|  |  | |||
|  | @ -22,6 +22,7 @@ import {Provider as MutedThreadsProvider} from 'state/muted-threads' | |||
| import {Provider as InvitesStateProvider} from 'state/invites' | ||||
| import {Provider as PrefsStateProvider} from 'state/preferences' | ||||
| import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' | ||||
| import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' | ||||
| import I18nProvider from './locale/i18nProvider' | ||||
| import { | ||||
|   Provider as SessionProvider, | ||||
|  | @ -52,17 +53,19 @@ function InnerApp() { | |||
|         // Resets the entire tree below when it changes:
 | ||||
|         key={currentAccount?.did}> | ||||
|         <LoggedOutViewProvider> | ||||
|           <UnreadNotifsProvider> | ||||
|             <ThemeProvider theme={theme}> | ||||
|               {/* All components should be within this provider */} | ||||
|               <RootSiblingParent> | ||||
|                 <SafeAreaProvider> | ||||
|                   <Shell /> | ||||
|                 </SafeAreaProvider> | ||||
|               </RootSiblingParent> | ||||
|               <ToastContainer /> | ||||
|             </ThemeProvider> | ||||
|           </UnreadNotifsProvider> | ||||
|           <SelectedFeedProvider> | ||||
|             <UnreadNotifsProvider> | ||||
|               <ThemeProvider theme={theme}> | ||||
|                 {/* All components should be within this provider */} | ||||
|                 <RootSiblingParent> | ||||
|                   <SafeAreaProvider> | ||||
|                     <Shell /> | ||||
|                   </SafeAreaProvider> | ||||
|                 </RootSiblingParent> | ||||
|                 <ToastContainer /> | ||||
|               </ThemeProvider> | ||||
|             </UnreadNotifsProvider> | ||||
|           </SelectedFeedProvider> | ||||
|         </LoggedOutViewProvider> | ||||
|       </React.Fragment> | ||||
|     </Alf> | ||||
|  |  | |||
							
								
								
									
										61
									
								
								src/state/shell/selected-feed.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/state/shell/selected-feed.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,61 @@ | |||
| import React from 'react' | ||||
| import * as persisted from '#/state/persisted' | ||||
| import {isWeb} from '#/platform/detection' | ||||
| 
 | ||||
| type StateContext = string | ||||
| type SetContext = (v: string) => void | ||||
| 
 | ||||
| const stateContext = React.createContext<StateContext>('home') | ||||
| const setContext = React.createContext<SetContext>((_: string) => {}) | ||||
| 
 | ||||
| function getInitialFeed() { | ||||
|   if (isWeb) { | ||||
|     if (window.location.pathname === '/') { | ||||
|       const params = new URLSearchParams(window.location.search) | ||||
|       const feedFromUrl = params.get('feed') | ||||
|       if (feedFromUrl) { | ||||
|         // If explicitly booted from a link like /?feed=..., prefer that.
 | ||||
|         return feedFromUrl | ||||
|       } | ||||
|     } | ||||
|     const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed') | ||||
|     if (feedFromSession) { | ||||
|       // Fall back to a previously chosen feed for this browser tab.
 | ||||
|       return feedFromSession | ||||
|     } | ||||
|   } | ||||
|   const feedFromPersisted = persisted.get('lastSelectedHomeFeed') | ||||
|   if (feedFromPersisted) { | ||||
|     // Fall back to the last chosen one across all tabs.
 | ||||
|     return feedFromPersisted | ||||
|   } | ||||
|   return 'home' | ||||
| } | ||||
| 
 | ||||
| export function Provider({children}: React.PropsWithChildren<{}>) { | ||||
|   const [state, setState] = React.useState(getInitialFeed) | ||||
| 
 | ||||
|   const saveState = React.useCallback((feed: string) => { | ||||
|     setState(feed) | ||||
|     if (isWeb) { | ||||
|       try { | ||||
|         sessionStorage.setItem('lastSelectedHomeFeed', feed) | ||||
|       } catch {} | ||||
|     } | ||||
|     persisted.write('lastSelectedHomeFeed', feed) | ||||
|   }, []) | ||||
| 
 | ||||
|   return ( | ||||
|     <stateContext.Provider value={state}> | ||||
|       <setContext.Provider value={saveState}>{children}</setContext.Provider> | ||||
|     </stateContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export function useSelectedFeed() { | ||||
|   return React.useContext(stateContext) | ||||
| } | ||||
| 
 | ||||
| export function useSetSelectedFeed() { | ||||
|   return React.useContext(setContext) | ||||
| } | ||||
|  | @ -31,7 +31,7 @@ export const Pager = React.forwardRef(function PagerImpl( | |||
|   const anchorRef = React.useRef(null) | ||||
| 
 | ||||
|   React.useImperativeHandle(ref, () => ({ | ||||
|     setPage: (index: number) => setSelectedPage(index), | ||||
|     setPage: (index: number) => onTabBarSelect(index), | ||||
|   })) | ||||
| 
 | ||||
|   const onTabBarSelect = React.useCallback( | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' | |||
| import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' | ||||
| import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' | ||||
| import {FeedsTabBar} from '../com/pager/FeedsTabBar' | ||||
| import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' | ||||
| import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager' | ||||
| import {FeedPage} from 'view/com/feeds/FeedPage' | ||||
| import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' | ||||
| import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' | ||||
|  | @ -16,25 +16,19 @@ import {usePinnedFeedsInfos, FeedSourceInfo} from '#/state/queries/feed' | |||
| import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' | ||||
| import {emitSoftReset} from '#/state/events' | ||||
| import {useSession} from '#/state/session' | ||||
| import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' | ||||
| import * as persisted from '#/state/persisted' | ||||
| import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' | ||||
| 
 | ||||
| type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> | ||||
| export function HomeScreen(props: Props) { | ||||
|   const {data: preferences} = usePreferencesQuery() | ||||
|   const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = | ||||
|     usePinnedFeedsInfos() | ||||
|   const {isDesktop} = useWebMediaQueries() | ||||
|   const [rawInitialFeed] = React.useState<string>( | ||||
|     () => persisted.get('lastSelectedHomeFeed') ?? 'home', | ||||
|   ) | ||||
|   if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { | ||||
|     return ( | ||||
|       <HomeScreenReady | ||||
|         {...props} | ||||
|         preferences={preferences} | ||||
|         pinnedFeedInfos={pinnedFeedInfos} | ||||
|         rawInitialFeed={isDesktop ? 'home' : rawInitialFeed} | ||||
|       /> | ||||
|     ) | ||||
|   } else { | ||||
|  | @ -49,11 +43,9 @@ export function HomeScreen(props: Props) { | |||
| function HomeScreenReady({ | ||||
|   preferences, | ||||
|   pinnedFeedInfos, | ||||
|   rawInitialFeed, | ||||
| }: Props & { | ||||
|   preferences: UsePreferencesQueryResponse | ||||
|   pinnedFeedInfos: FeedSourceInfo[] | ||||
|   rawInitialFeed: string | ||||
| }) { | ||||
|   const allFeeds = React.useMemo(() => { | ||||
|     const feeds: FeedDescriptor[] = [] | ||||
|  | @ -68,12 +60,24 @@ function HomeScreenReady({ | |||
|     return feeds | ||||
|   }, [pinnedFeedInfos]) | ||||
| 
 | ||||
|   const [rawSelectedFeed, setSelectedFeed] = | ||||
|     React.useState<string>(rawInitialFeed) | ||||
|   const rawSelectedFeed = useSelectedFeed() | ||||
|   const setSelectedFeed = useSetSelectedFeed() | ||||
|   const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor) | ||||
|   const selectedIndex = Math.max(0, maybeFoundIndex) | ||||
|   const selectedFeed = allFeeds[selectedIndex] | ||||
| 
 | ||||
|   const pagerRef = React.useRef<PagerRef>(null) | ||||
|   const lastPagerReportedIndexRef = React.useRef(selectedIndex) | ||||
|   React.useLayoutEffect(() => { | ||||
|     // Since the pager is not a controlled component, adjust it imperatively
 | ||||
|     // if the selected index gets out of sync with what it last reported.
 | ||||
|     // This is supposed to only happen on the web when you use the right nav.
 | ||||
|     if (selectedIndex !== lastPagerReportedIndexRef.current) { | ||||
|       lastPagerReportedIndexRef.current = selectedIndex | ||||
|       pagerRef.current?.setPage(selectedIndex) | ||||
|     } | ||||
|   }, [selectedIndex]) | ||||
| 
 | ||||
|   const {hasSession} = useSession() | ||||
|   const setMinimalShellMode = useSetMinimalShellMode() | ||||
|   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() | ||||
|  | @ -93,7 +97,7 @@ function HomeScreenReady({ | |||
|       setDrawerSwipeDisabled(index > 0) | ||||
|       const feed = allFeeds[index] | ||||
|       setSelectedFeed(feed) | ||||
|       persisted.write('lastSelectedHomeFeed', feed) | ||||
|       lastPagerReportedIndexRef.current = index | ||||
|     }, | ||||
|     [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], | ||||
|   ) | ||||
|  | @ -147,6 +151,7 @@ function HomeScreenReady({ | |||
|   return hasSession ? ( | ||||
|     <Pager | ||||
|       key={allFeeds.join(',')} | ||||
|       ref={pagerRef} | ||||
|       testID="homeScreen" | ||||
|       initialPage={selectedIndex} | ||||
|       onPageSelected={onPageSelected} | ||||
|  |  | |||
|  | @ -1,18 +1,24 @@ | |||
| import React from 'react' | ||||
| import {View, StyleSheet} from 'react-native' | ||||
| import {useNavigationState} from '@react-navigation/native' | ||||
| import {useNavigationState, useNavigation} from '@react-navigation/native' | ||||
| import {usePalette} from 'lib/hooks/usePalette' | ||||
| import {TextLink} from 'view/com/util/Link' | ||||
| import {getCurrentRoute} from 'lib/routes/helpers' | ||||
| import {useLingui} from '@lingui/react' | ||||
| import {msg} from '@lingui/macro' | ||||
| import {usePinnedFeedsInfos} from '#/state/queries/feed' | ||||
| import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' | ||||
| import {FeedDescriptor} from '#/state/queries/post-feed' | ||||
| import {NavigationProp} from 'lib/routes/types' | ||||
| import {emitSoftReset} from '#/state/events' | ||||
| 
 | ||||
| export function DesktopFeeds() { | ||||
|   const pal = usePalette('default') | ||||
|   const {_} = useLingui() | ||||
|   const {feeds} = usePinnedFeedsInfos() | ||||
| 
 | ||||
|   const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos() | ||||
|   const selectedFeed = useSelectedFeed() | ||||
|   const setSelectedFeed = useSetSelectedFeed() | ||||
|   const navigation = useNavigation<NavigationProp>() | ||||
|   const route = useNavigationState(state => { | ||||
|     if (!state) { | ||||
|       return {name: 'Home'} | ||||
|  | @ -22,30 +28,34 @@ export function DesktopFeeds() { | |||
| 
 | ||||
|   return ( | ||||
|     <View style={[styles.container, pal.view]}> | ||||
|       <FeedItem href="/" title="Following" current={route.name === 'Home'} /> | ||||
|       {feeds | ||||
|         .filter(f => f.displayName !== 'Following') | ||||
|         .map(feed => { | ||||
|           try { | ||||
|             const params = route.params as Record<string, string> | ||||
|             const routeName = | ||||
|               feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList' | ||||
|             return ( | ||||
|               <FeedItem | ||||
|                 key={feed.uri} | ||||
|                 href={feed.route.href} | ||||
|                 title={feed.displayName} | ||||
|                 current={ | ||||
|                   route.name === routeName && | ||||
|                   params.name === feed.route.params.name && | ||||
|                   params.rkey === feed.route.params.rkey | ||||
|                 } | ||||
|               /> | ||||
|             ) | ||||
|           } catch { | ||||
|             return null | ||||
|           } | ||||
|         })} | ||||
|       {pinnedFeedInfos.map(feedInfo => { | ||||
|         const uri = feedInfo.uri | ||||
|         let feed: FeedDescriptor | ||||
|         if (!uri) { | ||||
|           feed = 'home' | ||||
|         } else if (uri.includes('app.bsky.feed.generator')) { | ||||
|           feed = `feedgen|${uri}` | ||||
|         } else if (uri.includes('app.bsky.graph.list')) { | ||||
|           feed = `list|${uri}` | ||||
|         } else { | ||||
|           return null | ||||
|         } | ||||
|         return ( | ||||
|           <FeedItem | ||||
|             key={feed} | ||||
|             href={'/?' + new URLSearchParams([['feed', feed]])} | ||||
|             title={feedInfo.displayName} | ||||
|             current={route.name === 'Home' && feed === selectedFeed} | ||||
|             onPress={() => { | ||||
|               setSelectedFeed(feed) | ||||
|               navigation.navigate('Home') | ||||
|               if (feed === selectedFeed) { | ||||
|                 emitSoftReset() | ||||
|               } | ||||
|             }} | ||||
|           /> | ||||
|         ) | ||||
|       })} | ||||
|       <View style={{paddingTop: 8, paddingBottom: 6}}> | ||||
|         <TextLink | ||||
|           type="lg" | ||||
|  | @ -62,10 +72,12 @@ function FeedItem({ | |||
|   title, | ||||
|   href, | ||||
|   current, | ||||
|   onPress, | ||||
| }: { | ||||
|   title: string | ||||
|   href: string | ||||
|   current: boolean | ||||
|   onPress: () => void | ||||
| }) { | ||||
|   const pal = usePalette('default') | ||||
|   return ( | ||||
|  | @ -74,6 +86,7 @@ function FeedItem({ | |||
|         type="xl" | ||||
|         href={href} | ||||
|         text={title} | ||||
|         onPress={onPress} | ||||
|         style={[ | ||||
|           current ? pal.text : pal.textLight, | ||||
|           {letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'}, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue