Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

dan and committed by
GitHub
06f81d69 80c482b0

+148 -63
+14 -11
src/App.native.tsx
··· 32 32 import {Provider as InvitesStateProvider} from 'state/invites' 33 33 import {Provider as PrefsStateProvider} from 'state/preferences' 34 34 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' 35 + import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' 35 36 import I18nProvider from './locale/i18nProvider' 36 37 import { 37 38 Provider as SessionProvider, ··· 72 73 // Resets the entire tree below when it changes: 73 74 key={currentAccount?.did}> 74 75 <LoggedOutViewProvider> 75 - <UnreadNotifsProvider> 76 - <ThemeProvider theme={theme}> 77 - {/* All components should be within this provider */} 78 - <RootSiblingParent> 79 - <GestureHandlerRootView style={s.h100pct}> 80 - <TestCtrls /> 81 - <Shell /> 82 - </GestureHandlerRootView> 83 - </RootSiblingParent> 84 - </ThemeProvider> 85 - </UnreadNotifsProvider> 76 + <SelectedFeedProvider> 77 + <UnreadNotifsProvider> 78 + <ThemeProvider theme={theme}> 79 + {/* All components should be within this provider */} 80 + <RootSiblingParent> 81 + <GestureHandlerRootView style={s.h100pct}> 82 + <TestCtrls /> 83 + <Shell /> 84 + </GestureHandlerRootView> 85 + </RootSiblingParent> 86 + </ThemeProvider> 87 + </UnreadNotifsProvider> 88 + </SelectedFeedProvider> 86 89 </LoggedOutViewProvider> 87 90 </React.Fragment> 88 91 </Splash>
+14 -11
src/App.web.tsx
··· 22 22 import {Provider as InvitesStateProvider} from 'state/invites' 23 23 import {Provider as PrefsStateProvider} from 'state/preferences' 24 24 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' 25 + import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' 25 26 import I18nProvider from './locale/i18nProvider' 26 27 import { 27 28 Provider as SessionProvider, ··· 52 53 // Resets the entire tree below when it changes: 53 54 key={currentAccount?.did}> 54 55 <LoggedOutViewProvider> 55 - <UnreadNotifsProvider> 56 - <ThemeProvider theme={theme}> 57 - {/* All components should be within this provider */} 58 - <RootSiblingParent> 59 - <SafeAreaProvider> 60 - <Shell /> 61 - </SafeAreaProvider> 62 - </RootSiblingParent> 63 - <ToastContainer /> 64 - </ThemeProvider> 65 - </UnreadNotifsProvider> 56 + <SelectedFeedProvider> 57 + <UnreadNotifsProvider> 58 + <ThemeProvider theme={theme}> 59 + {/* All components should be within this provider */} 60 + <RootSiblingParent> 61 + <SafeAreaProvider> 62 + <Shell /> 63 + </SafeAreaProvider> 64 + </RootSiblingParent> 65 + <ToastContainer /> 66 + </ThemeProvider> 67 + </UnreadNotifsProvider> 68 + </SelectedFeedProvider> 66 69 </LoggedOutViewProvider> 67 70 </React.Fragment> 68 71 </Alf>
+61
src/state/shell/selected-feed.tsx
··· 1 + import React from 'react' 2 + import * as persisted from '#/state/persisted' 3 + import {isWeb} from '#/platform/detection' 4 + 5 + type StateContext = string 6 + type SetContext = (v: string) => void 7 + 8 + const stateContext = React.createContext<StateContext>('home') 9 + const setContext = React.createContext<SetContext>((_: string) => {}) 10 + 11 + function getInitialFeed() { 12 + if (isWeb) { 13 + if (window.location.pathname === '/') { 14 + const params = new URLSearchParams(window.location.search) 15 + const feedFromUrl = params.get('feed') 16 + if (feedFromUrl) { 17 + // If explicitly booted from a link like /?feed=..., prefer that. 18 + return feedFromUrl 19 + } 20 + } 21 + const feedFromSession = sessionStorage.getItem('lastSelectedHomeFeed') 22 + if (feedFromSession) { 23 + // Fall back to a previously chosen feed for this browser tab. 24 + return feedFromSession 25 + } 26 + } 27 + const feedFromPersisted = persisted.get('lastSelectedHomeFeed') 28 + if (feedFromPersisted) { 29 + // Fall back to the last chosen one across all tabs. 30 + return feedFromPersisted 31 + } 32 + return 'home' 33 + } 34 + 35 + export function Provider({children}: React.PropsWithChildren<{}>) { 36 + const [state, setState] = React.useState(getInitialFeed) 37 + 38 + const saveState = React.useCallback((feed: string) => { 39 + setState(feed) 40 + if (isWeb) { 41 + try { 42 + sessionStorage.setItem('lastSelectedHomeFeed', feed) 43 + } catch {} 44 + } 45 + persisted.write('lastSelectedHomeFeed', feed) 46 + }, []) 47 + 48 + return ( 49 + <stateContext.Provider value={state}> 50 + <setContext.Provider value={saveState}>{children}</setContext.Provider> 51 + </stateContext.Provider> 52 + ) 53 + } 54 + 55 + export function useSelectedFeed() { 56 + return React.useContext(stateContext) 57 + } 58 + 59 + export function useSetSelectedFeed() { 60 + return React.useContext(setContext) 61 + }
+1 -1
src/view/com/pager/Pager.web.tsx
··· 31 31 const anchorRef = React.useRef(null) 32 32 33 33 React.useImperativeHandle(ref, () => ({ 34 - setPage: (index: number) => setSelectedPage(index), 34 + setPage: (index: number) => onTabBarSelect(index), 35 35 })) 36 36 37 37 const onTabBarSelect = React.useCallback(
+18 -13
src/view/screens/Home.tsx
··· 7 7 import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' 8 8 import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' 9 9 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 10 - import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' 10 + import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager' 11 11 import {FeedPage} from 'view/com/feeds/FeedPage' 12 12 import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' 13 13 import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' ··· 16 16 import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 17 17 import {emitSoftReset} from '#/state/events' 18 18 import {useSession} from '#/state/session' 19 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 20 - import * as persisted from '#/state/persisted' 19 + import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 21 20 22 21 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 23 22 export function HomeScreen(props: Props) { 24 23 const {data: preferences} = usePreferencesQuery() 25 24 const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = 26 25 usePinnedFeedsInfos() 27 - const {isDesktop} = useWebMediaQueries() 28 - const [rawInitialFeed] = React.useState<string>( 29 - () => persisted.get('lastSelectedHomeFeed') ?? 'home', 30 - ) 31 26 if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { 32 27 return ( 33 28 <HomeScreenReady 34 29 {...props} 35 30 preferences={preferences} 36 31 pinnedFeedInfos={pinnedFeedInfos} 37 - rawInitialFeed={isDesktop ? 'home' : rawInitialFeed} 38 32 /> 39 33 ) 40 34 } else { ··· 49 43 function HomeScreenReady({ 50 44 preferences, 51 45 pinnedFeedInfos, 52 - rawInitialFeed, 53 46 }: Props & { 54 47 preferences: UsePreferencesQueryResponse 55 48 pinnedFeedInfos: FeedSourceInfo[] 56 - rawInitialFeed: string 57 49 }) { 58 50 const allFeeds = React.useMemo(() => { 59 51 const feeds: FeedDescriptor[] = [] ··· 68 60 return feeds 69 61 }, [pinnedFeedInfos]) 70 62 71 - const [rawSelectedFeed, setSelectedFeed] = 72 - React.useState<string>(rawInitialFeed) 63 + const rawSelectedFeed = useSelectedFeed() 64 + const setSelectedFeed = useSetSelectedFeed() 73 65 const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor) 74 66 const selectedIndex = Math.max(0, maybeFoundIndex) 75 67 const selectedFeed = allFeeds[selectedIndex] 76 68 69 + const pagerRef = React.useRef<PagerRef>(null) 70 + const lastPagerReportedIndexRef = React.useRef(selectedIndex) 71 + React.useLayoutEffect(() => { 72 + // Since the pager is not a controlled component, adjust it imperatively 73 + // if the selected index gets out of sync with what it last reported. 74 + // This is supposed to only happen on the web when you use the right nav. 75 + if (selectedIndex !== lastPagerReportedIndexRef.current) { 76 + lastPagerReportedIndexRef.current = selectedIndex 77 + pagerRef.current?.setPage(selectedIndex) 78 + } 79 + }, [selectedIndex]) 80 + 77 81 const {hasSession} = useSession() 78 82 const setMinimalShellMode = useSetMinimalShellMode() 79 83 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() ··· 93 97 setDrawerSwipeDisabled(index > 0) 94 98 const feed = allFeeds[index] 95 99 setSelectedFeed(feed) 96 - persisted.write('lastSelectedHomeFeed', feed) 100 + lastPagerReportedIndexRef.current = index 97 101 }, 98 102 [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], 99 103 ) ··· 147 151 return hasSession ? ( 148 152 <Pager 149 153 key={allFeeds.join(',')} 154 + ref={pagerRef} 150 155 testID="homeScreen" 151 156 initialPage={selectedIndex} 152 157 onPageSelected={onPageSelected}
+40 -27
src/view/shell/desktop/Feeds.tsx
··· 1 1 import React from 'react' 2 2 import {View, StyleSheet} from 'react-native' 3 - import {useNavigationState} from '@react-navigation/native' 3 + import {useNavigationState, useNavigation} from '@react-navigation/native' 4 4 import {usePalette} from 'lib/hooks/usePalette' 5 5 import {TextLink} from 'view/com/util/Link' 6 6 import {getCurrentRoute} from 'lib/routes/helpers' 7 7 import {useLingui} from '@lingui/react' 8 8 import {msg} from '@lingui/macro' 9 9 import {usePinnedFeedsInfos} from '#/state/queries/feed' 10 + import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 11 + import {FeedDescriptor} from '#/state/queries/post-feed' 12 + import {NavigationProp} from 'lib/routes/types' 13 + import {emitSoftReset} from '#/state/events' 10 14 11 15 export function DesktopFeeds() { 12 16 const pal = usePalette('default') 13 17 const {_} = useLingui() 14 - const {feeds} = usePinnedFeedsInfos() 15 - 18 + const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos() 19 + const selectedFeed = useSelectedFeed() 20 + const setSelectedFeed = useSetSelectedFeed() 21 + const navigation = useNavigation<NavigationProp>() 16 22 const route = useNavigationState(state => { 17 23 if (!state) { 18 24 return {name: 'Home'} ··· 22 28 23 29 return ( 24 30 <View style={[styles.container, pal.view]}> 25 - <FeedItem href="/" title="Following" current={route.name === 'Home'} /> 26 - {feeds 27 - .filter(f => f.displayName !== 'Following') 28 - .map(feed => { 29 - try { 30 - const params = route.params as Record<string, string> 31 - const routeName = 32 - feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList' 33 - return ( 34 - <FeedItem 35 - key={feed.uri} 36 - href={feed.route.href} 37 - title={feed.displayName} 38 - current={ 39 - route.name === routeName && 40 - params.name === feed.route.params.name && 41 - params.rkey === feed.route.params.rkey 42 - } 43 - /> 44 - ) 45 - } catch { 46 - return null 47 - } 48 - })} 31 + {pinnedFeedInfos.map(feedInfo => { 32 + const uri = feedInfo.uri 33 + let feed: FeedDescriptor 34 + if (!uri) { 35 + feed = 'home' 36 + } else if (uri.includes('app.bsky.feed.generator')) { 37 + feed = `feedgen|${uri}` 38 + } else if (uri.includes('app.bsky.graph.list')) { 39 + feed = `list|${uri}` 40 + } else { 41 + return null 42 + } 43 + return ( 44 + <FeedItem 45 + key={feed} 46 + href={'/?' + new URLSearchParams([['feed', feed]])} 47 + title={feedInfo.displayName} 48 + current={route.name === 'Home' && feed === selectedFeed} 49 + onPress={() => { 50 + setSelectedFeed(feed) 51 + navigation.navigate('Home') 52 + if (feed === selectedFeed) { 53 + emitSoftReset() 54 + } 55 + }} 56 + /> 57 + ) 58 + })} 49 59 <View style={{paddingTop: 8, paddingBottom: 6}}> 50 60 <TextLink 51 61 type="lg" ··· 62 72 title, 63 73 href, 64 74 current, 75 + onPress, 65 76 }: { 66 77 title: string 67 78 href: string 68 79 current: boolean 80 + onPress: () => void 69 81 }) { 70 82 const pal = usePalette('default') 71 83 return ( ··· 74 86 type="xl" 75 87 href={href} 76 88 text={title} 89 + onPress={onPress} 77 90 style={[ 78 91 current ? pal.text : pal.textLight, 79 92 {letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},