Implement "scroll to top" for profile tabs (#1973)

* Hook up scroll to top handlers

* Scroll and invalidate Feeds/Lists

* Fix index calc due to conditional tabs

* Reorder lines for clarity
zio/stable
dan 2023-11-22 04:25:11 +00:00 committed by GitHub
parent 3de1d556a9
commit 08333002cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 275 additions and 181 deletions

View File

@ -8,13 +8,14 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {FeedSourceCardLoaded} from './FeedSourceCard' import {FeedSourceCardLoaded} from './FeedSourceCard'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
@ -29,25 +30,37 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export function ProfileFeedgens({ interface SectionRef {
did, scrollToTop: () => void
scrollElRef, }
onScroll,
scrollEventThrottle, interface ProfileFeedgensProps {
headerOffset,
enabled,
style,
testID,
}: {
did: string did: string
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler onScroll?: OnScrollHandler
scrollEventThrottle?: number scrollEventThrottle?: number
headerOffset: number headerOffset: number
enabled?: boolean enabled?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
testID?: string testID?: string
}) { }
export const ProfileFeedgens = React.forwardRef<
SectionRef,
ProfileFeedgensProps
>(function ProfileFeedgensImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
ref,
) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
@ -88,6 +101,17 @@ export function ProfileFeedgens({
// events // events
// = // =
const queryClient = useQueryClient()
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
queryClient.invalidateQueries({queryKey: RQKEY(did)})
}, [scrollElRef, queryClient, headerOffset, did])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const onRefresh = React.useCallback(async () => { const onRefresh = React.useCallback(async () => {
setIsPTRing(true) setIsPTRing(true)
try { try {
@ -192,7 +216,7 @@ export function ProfileFeedgens({
/> />
</View> </View>
) )
} })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
item: { item: {

View File

@ -8,6 +8,7 @@ import {
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {FlatList} from '../util/Views' import {FlatList} from '../util/Views'
import {ListCard} from './ListCard' import {ListCard} from './ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
@ -15,7 +16,7 @@ import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useProfileListsQuery} from '#/state/queries/profile-lists' import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
@ -28,175 +29,200 @@ const EMPTY = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'} const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
export function ProfileLists({ interface SectionRef {
did, scrollToTop: () => void
scrollElRef, }
onScroll,
scrollEventThrottle, interface ProfileListsProps {
headerOffset,
enabled,
style,
testID,
}: {
did: string did: string
scrollElRef?: MutableRefObject<FlatList<any> | null> scrollElRef: MutableRefObject<FlatList<any> | null>
onScroll?: OnScrollHandler onScroll?: OnScrollHandler
scrollEventThrottle?: number scrollEventThrottle?: number
headerOffset: number headerOffset: number
enabled?: boolean enabled?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
testID?: string testID?: string
}) {
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
isFetching,
isFetched,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileListsQuery(did, opts)
const isEmpty = !isFetching && !data?.pages[0]?.lists.length
const items = React.useMemo(() => {
let items: any[] = []
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
}
if (!isFetched && isFetching) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
items = items.concat(
page.lists.map(l => ({
...l,
_reactKey: l.uri,
})),
)
}
}
if (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [isError, isEmpty, isFetched, isFetching, data])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh lists', {error: err})
}
setIsPTRing(false)
}, [refetch, track, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
track('Lists:onEndReached')
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more lists', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage, track])
const onPressRetryLoadMore = React.useCallback(() => {
fetchNextPage()
}, [fetchNextPage])
// rendering
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY) {
return (
<View
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
)
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING) {
return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/>
)
},
[error, refetch, onPressRetryLoadMore, pal],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
/>
</View>
)
} }
export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
function ProfileListsImpl(
{
did,
scrollElRef,
onScroll,
scrollEventThrottle,
headerOffset,
enabled,
style,
testID,
},
ref,
) {
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
data,
isFetching,
isFetched,
hasNextPage,
fetchNextPage,
isError,
error,
refetch,
} = useProfileListsQuery(did, opts)
const isEmpty = !isFetching && !data?.pages[0]?.lists.length
const items = React.useMemo(() => {
let items: any[] = []
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
}
if (!isFetched && isFetching) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
items = items.concat(
page.lists.map(l => ({
...l,
_reactKey: l.uri,
})),
)
}
}
if (isError && !isEmpty) {
items = items.concat([LOAD_MORE_ERROR_ITEM])
}
return items
}, [isError, isEmpty, isFetched, isFetching, data])
// events
// =
const queryClient = useQueryClient()
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerOffset})
queryClient.invalidateQueries({queryKey: RQKEY(did)})
}, [scrollElRef, queryClient, headerOffset, did])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const onRefresh = React.useCallback(async () => {
track('Lists:onRefresh')
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh lists', {error: err})
}
setIsPTRing(false)
}, [refetch, track, setIsPTRing])
const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
track('Lists:onEndReached')
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more lists', {error: err})
}
}, [isFetching, hasNextPage, isError, fetchNextPage, track])
const onPressRetryLoadMore = React.useCallback(() => {
fetchNextPage()
}, [fetchNextPage])
// rendering
// =
const renderItemInner = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY) {
return (
<View
testID="listsEmpty"
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
<Text style={pal.textLight}>
<Trans>You have no lists.</Trans>
</Text>
</View>
)
} else if (item === ERROR_ITEM) {
return (
<ErrorMessage
message={cleanError(error)}
onPressTryAgain={refetch}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
onPress={onPressRetryLoadMore}
/>
)
} else if (item === LOADING) {
return (
<View style={{padding: 20}}>
<ActivityIndicator />
</View>
)
}
return (
<ListCard
list={item}
testID={`list-${item.name}`}
style={styles.item}
/>
)
},
[error, refetch, onPressRetryLoadMore, pal],
)
const scrollHandler = useAnimatedScrollHandler(onScroll || {})
return (
<View testID={testID} style={style}>
<FlatList
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={(item: any) => item._reactKey}
renderItem={renderItemInner}
refreshControl={
<RefreshControl
refreshing={isPTRing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
removeClippedSubviews={true}
contentOffset={{x: 0, y: headerOffset * -1}}
// @ts-ignore our .web version only -prf
desktopFixedHeight
onEndReached={onEndReached}
/>
</View>
)
},
)
const styles = StyleSheet.create({ const styles = StyleSheet.create({
item: { item: {
paddingHorizontal: 18, paddingHorizontal: 18,

View File

@ -140,6 +140,12 @@ function ProfileScreenLoaded({
const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const extraInfoQuery = useProfileExtraInfoQuery(profile.did) const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
const postsSectionRef = React.useRef<SectionRef>(null)
const repliesSectionRef = React.useRef<SectionRef>(null)
const mediaSectionRef = React.useRef<SectionRef>(null)
const likesSectionRef = React.useRef<SectionRef>(null)
const feedsSectionRef = React.useRef<SectionRef>(null)
const listsSectionRef = React.useRef<SectionRef>(null)
useSetTitle(combinedDisplayName(profile)) useSetTitle(combinedDisplayName(profile))
@ -163,6 +169,23 @@ function ProfileScreenLoaded({
].filter(Boolean) as string[] ].filter(Boolean) as string[]
}, [showLikesTab, showFeedsTab, showListsTab]) }, [showLikesTab, showFeedsTab, showListsTab])
let nextIndex = 0
const postsIndex = nextIndex++
const repliesIndex = nextIndex++
const mediaIndex = nextIndex++
let likesIndex: number | null = null
if (showLikesTab) {
likesIndex = nextIndex++
}
let feedsIndex: number | null = null
if (showFeedsTab) {
feedsIndex = nextIndex++
}
let listsIndex: number | null = null
if (showListsTab) {
listsIndex = nextIndex++
}
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
setMinimalShellMode(false) setMinimalShellMode(false)
@ -202,6 +225,25 @@ function ProfileScreenLoaded({
[setCurrentPage], [setCurrentPage],
) )
const onCurrentPageSelected = React.useCallback(
(index: number) => {
if (index === postsIndex) {
postsSectionRef.current?.scrollToTop()
} else if (index === repliesIndex) {
repliesSectionRef.current?.scrollToTop()
} else if (index === mediaIndex) {
mediaSectionRef.current?.scrollToTop()
} else if (index === likesIndex) {
likesSectionRef.current?.scrollToTop()
} else if (index === feedsIndex) {
feedsSectionRef.current?.scrollToTop()
} else if (index === listsIndex) {
listsSectionRef.current?.scrollToTop()
}
},
[postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex],
)
// rendering // rendering
// = // =
@ -225,10 +267,11 @@ function ProfileScreenLoaded({
isHeaderReady={true} isHeaderReady={true}
items={sectionTitles} items={sectionTitles}
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}> renderHeader={renderHeader}>
{({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
<FeedSection <FeedSection
ref={null} ref={postsSectionRef}
feed={`author|${profile.did}|posts_no_replies`} feed={`author|${profile.did}|posts_no_replies`}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
@ -241,7 +284,7 @@ function ProfileScreenLoaded({
)} )}
{({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
<FeedSection <FeedSection
ref={null} ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`} feed={`author|${profile.did}|posts_with_replies`}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
@ -254,7 +297,7 @@ function ProfileScreenLoaded({
)} )}
{({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => (
<FeedSection <FeedSection
ref={null} ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`} feed={`author|${profile.did}|posts_with_media`}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
@ -274,7 +317,7 @@ function ProfileScreenLoaded({
scrollElRef, scrollElRef,
}) => ( }) => (
<FeedSection <FeedSection
ref={null} ref={likesSectionRef}
feed={`likes|${profile.did}`} feed={`likes|${profile.did}`}
onScroll={onScroll} onScroll={onScroll}
headerHeight={headerHeight} headerHeight={headerHeight}
@ -289,6 +332,7 @@ function ProfileScreenLoaded({
{showFeedsTab {showFeedsTab
? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
<ProfileFeedgens <ProfileFeedgens
ref={feedsSectionRef}
did={profile.did} did={profile.did}
scrollElRef={ scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null> scrollElRef as React.MutableRefObject<FlatList<any> | null>
@ -303,6 +347,7 @@ function ProfileScreenLoaded({
{showListsTab {showListsTab
? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( ? ({onScroll, headerHeight, isFocused, scrollElRef}) => (
<ProfileLists <ProfileLists
ref={listsSectionRef}
did={profile.did} did={profile.did}
scrollElRef={ scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null> scrollElRef as React.MutableRefObject<FlatList<any> | null>

View File

@ -143,8 +143,7 @@ function ProfileListScreenLoaded({
(index: number) => { (index: number) => {
if (index === 0) { if (index === 0) {
feedSectionRef.current?.scrollToTop() feedSectionRef.current?.scrollToTop()
} } else if (index === 1) {
if (index === 1) {
aboutSectionRef.current?.scrollToTop() aboutSectionRef.current?.scrollToTop()
} }
}, },