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 clickzio/stable
parent
80c482b026
commit
06f81d6948
|
@ -32,6 +32,7 @@ import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||||
|
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
|
@ -72,17 +73,19 @@ function InnerApp() {
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<UnreadNotifsProvider>
|
<SelectedFeedProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<UnreadNotifsProvider>
|
||||||
{/* All components should be within this provider */}
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
{/* All components should be within this provider */}
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<RootSiblingParent>
|
||||||
<TestCtrls />
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
</GestureHandlerRootView>
|
<Shell />
|
||||||
</RootSiblingParent>
|
</GestureHandlerRootView>
|
||||||
</ThemeProvider>
|
</RootSiblingParent>
|
||||||
</UnreadNotifsProvider>
|
</ThemeProvider>
|
||||||
|
</UnreadNotifsProvider>
|
||||||
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</Splash>
|
</Splash>
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||||
|
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
import I18nProvider from './locale/i18nProvider'
|
||||||
import {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
|
@ -52,17 +53,19 @@ function InnerApp() {
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<LoggedOutViewProvider>
|
<LoggedOutViewProvider>
|
||||||
<UnreadNotifsProvider>
|
<SelectedFeedProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<UnreadNotifsProvider>
|
||||||
{/* All components should be within this provider */}
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
{/* All components should be within this provider */}
|
||||||
<SafeAreaProvider>
|
<RootSiblingParent>
|
||||||
<Shell />
|
<SafeAreaProvider>
|
||||||
</SafeAreaProvider>
|
<Shell />
|
||||||
</RootSiblingParent>
|
</SafeAreaProvider>
|
||||||
<ToastContainer />
|
</RootSiblingParent>
|
||||||
</ThemeProvider>
|
<ToastContainer />
|
||||||
</UnreadNotifsProvider>
|
</ThemeProvider>
|
||||||
|
</UnreadNotifsProvider>
|
||||||
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
|
|
@ -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)
|
const anchorRef = React.useRef(null)
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
setPage: (index: number) => setSelectedPage(index),
|
setPage: (index: number) => onTabBarSelect(index),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const onTabBarSelect = React.useCallback(
|
const onTabBarSelect = React.useCallback(
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||||
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||||
import {FeedsTabBar} from '../com/pager/FeedsTabBar'
|
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 {FeedPage} from 'view/com/feeds/FeedPage'
|
||||||
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA'
|
||||||
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
|
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 {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||||
import * as persisted from '#/state/persisted'
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||||
export function HomeScreen(props: Props) {
|
export function HomeScreen(props: Props) {
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
|
const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
|
||||||
usePinnedFeedsInfos()
|
usePinnedFeedsInfos()
|
||||||
const {isDesktop} = useWebMediaQueries()
|
|
||||||
const [rawInitialFeed] = React.useState<string>(
|
|
||||||
() => persisted.get('lastSelectedHomeFeed') ?? 'home',
|
|
||||||
)
|
|
||||||
if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) {
|
if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) {
|
||||||
return (
|
return (
|
||||||
<HomeScreenReady
|
<HomeScreenReady
|
||||||
{...props}
|
{...props}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
pinnedFeedInfos={pinnedFeedInfos}
|
pinnedFeedInfos={pinnedFeedInfos}
|
||||||
rawInitialFeed={isDesktop ? 'home' : rawInitialFeed}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -49,11 +43,9 @@ export function HomeScreen(props: Props) {
|
||||||
function HomeScreenReady({
|
function HomeScreenReady({
|
||||||
preferences,
|
preferences,
|
||||||
pinnedFeedInfos,
|
pinnedFeedInfos,
|
||||||
rawInitialFeed,
|
|
||||||
}: Props & {
|
}: Props & {
|
||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
pinnedFeedInfos: FeedSourceInfo[]
|
pinnedFeedInfos: FeedSourceInfo[]
|
||||||
rawInitialFeed: string
|
|
||||||
}) {
|
}) {
|
||||||
const allFeeds = React.useMemo(() => {
|
const allFeeds = React.useMemo(() => {
|
||||||
const feeds: FeedDescriptor[] = []
|
const feeds: FeedDescriptor[] = []
|
||||||
|
@ -68,12 +60,24 @@ function HomeScreenReady({
|
||||||
return feeds
|
return feeds
|
||||||
}, [pinnedFeedInfos])
|
}, [pinnedFeedInfos])
|
||||||
|
|
||||||
const [rawSelectedFeed, setSelectedFeed] =
|
const rawSelectedFeed = useSelectedFeed()
|
||||||
React.useState<string>(rawInitialFeed)
|
const setSelectedFeed = useSetSelectedFeed()
|
||||||
const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
|
const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed as FeedDescriptor)
|
||||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||||
const selectedFeed = allFeeds[selectedIndex]
|
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 {hasSession} = useSession()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||||
|
@ -93,7 +97,7 @@ function HomeScreenReady({
|
||||||
setDrawerSwipeDisabled(index > 0)
|
setDrawerSwipeDisabled(index > 0)
|
||||||
const feed = allFeeds[index]
|
const feed = allFeeds[index]
|
||||||
setSelectedFeed(feed)
|
setSelectedFeed(feed)
|
||||||
persisted.write('lastSelectedHomeFeed', feed)
|
lastPagerReportedIndexRef.current = index
|
||||||
},
|
},
|
||||||
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
|
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
|
||||||
)
|
)
|
||||||
|
@ -147,6 +151,7 @@ function HomeScreenReady({
|
||||||
return hasSession ? (
|
return hasSession ? (
|
||||||
<Pager
|
<Pager
|
||||||
key={allFeeds.join(',')}
|
key={allFeeds.join(',')}
|
||||||
|
ref={pagerRef}
|
||||||
testID="homeScreen"
|
testID="homeScreen"
|
||||||
initialPage={selectedIndex}
|
initialPage={selectedIndex}
|
||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
|
|
|
@ -1,18 +1,24 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View, StyleSheet} from 'react-native'
|
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 {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {TextLink} from 'view/com/util/Link'
|
import {TextLink} from 'view/com/util/Link'
|
||||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {usePinnedFeedsInfos} from '#/state/queries/feed'
|
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() {
|
export function DesktopFeeds() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {feeds} = usePinnedFeedsInfos()
|
const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos()
|
||||||
|
const selectedFeed = useSelectedFeed()
|
||||||
|
const setSelectedFeed = useSetSelectedFeed()
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const route = useNavigationState(state => {
|
const route = useNavigationState(state => {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return {name: 'Home'}
|
return {name: 'Home'}
|
||||||
|
@ -22,30 +28,34 @@ export function DesktopFeeds() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, pal.view]}>
|
<View style={[styles.container, pal.view]}>
|
||||||
<FeedItem href="/" title="Following" current={route.name === 'Home'} />
|
{pinnedFeedInfos.map(feedInfo => {
|
||||||
{feeds
|
const uri = feedInfo.uri
|
||||||
.filter(f => f.displayName !== 'Following')
|
let feed: FeedDescriptor
|
||||||
.map(feed => {
|
if (!uri) {
|
||||||
try {
|
feed = 'home'
|
||||||
const params = route.params as Record<string, string>
|
} else if (uri.includes('app.bsky.feed.generator')) {
|
||||||
const routeName =
|
feed = `feedgen|${uri}`
|
||||||
feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList'
|
} else if (uri.includes('app.bsky.graph.list')) {
|
||||||
return (
|
feed = `list|${uri}`
|
||||||
<FeedItem
|
} else {
|
||||||
key={feed.uri}
|
return null
|
||||||
href={feed.route.href}
|
}
|
||||||
title={feed.displayName}
|
return (
|
||||||
current={
|
<FeedItem
|
||||||
route.name === routeName &&
|
key={feed}
|
||||||
params.name === feed.route.params.name &&
|
href={'/?' + new URLSearchParams([['feed', feed]])}
|
||||||
params.rkey === feed.route.params.rkey
|
title={feedInfo.displayName}
|
||||||
}
|
current={route.name === 'Home' && feed === selectedFeed}
|
||||||
/>
|
onPress={() => {
|
||||||
)
|
setSelectedFeed(feed)
|
||||||
} catch {
|
navigation.navigate('Home')
|
||||||
return null
|
if (feed === selectedFeed) {
|
||||||
}
|
emitSoftReset()
|
||||||
})}
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<View style={{paddingTop: 8, paddingBottom: 6}}>
|
<View style={{paddingTop: 8, paddingBottom: 6}}>
|
||||||
<TextLink
|
<TextLink
|
||||||
type="lg"
|
type="lg"
|
||||||
|
@ -62,10 +72,12 @@ function FeedItem({
|
||||||
title,
|
title,
|
||||||
href,
|
href,
|
||||||
current,
|
current,
|
||||||
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
href: string
|
href: string
|
||||||
current: boolean
|
current: boolean
|
||||||
|
onPress: () => void
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
return (
|
return (
|
||||||
|
@ -74,6 +86,7 @@ function FeedItem({
|
||||||
type="xl"
|
type="xl"
|
||||||
href={href}
|
href={href}
|
||||||
text={title}
|
text={title}
|
||||||
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
current ? pal.text : pal.textLight,
|
current ? pal.text : pal.textLight,
|
||||||
{letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},
|
{letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'},
|
||||||
|
|
Loading…
Reference in New Issue