diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index ec8d6b33..b18aec52 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,10 +1,6 @@ import ComponentSeparator from '@components/Separator' import { useScrollToTop } from '@react-navigation/native' -import { - QueryKeyTimeline, - TimelineData, - useTimelineQuery -} from '@utils/queryHooks/timeline' +import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { getInstanceActive } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' @@ -16,10 +12,20 @@ import { RefreshControl, StyleSheet } from 'react-native' -import { InfiniteData, useQueryClient } from 'react-query' +import Animated, { + useAnimatedScrollHandler, + useSharedValue +} from 'react-native-reanimated' +import { useQueryClient } from 'react-query' import { useSelector } from 'react-redux' import TimelineEmpty from './Timeline/Empty' import TimelineFooter from './Timeline/Footer' +import TimelineRefresh, { + SEPARATION_Y_1, + SEPARATION_Y_2 +} from './Timeline/Refresh' + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export interface Props { flRef?: RefObject> @@ -40,15 +46,12 @@ const Timeline: React.FC = ({ }) => { const { colors } = useTheme() - const queryClient = useQueryClient() const { data, refetch, isFetching, isLoading, - fetchPreviousPage, fetchNextPage, - isFetchingPreviousPage, isFetchingNextPage } = useTimelineQuery({ ...queryKey[1], @@ -57,12 +60,6 @@ const Timeline: React.FC = ({ ios: ['dataUpdatedAt', 'isFetching'], android: ['dataUpdatedAt', 'isFetching', 'isLoading'] }), - getPreviousPageParam: firstPage => - firstPage?.links?.prev && { - min_id: firstPage.links.prev, - // https://github.com/facebook/react-native/issues/25239 - limit: '10' - }, getNextPageParam: lastPage => lastPage?.links?.next && { max_id: lastPage.links.next @@ -92,6 +89,27 @@ const Timeline: React.FC = ({ const flRef = useRef(null) + const scrollY = useSharedValue(0) + const fetchingType = useSharedValue<0 | 1 | 2>(0) + + const onScroll = useAnimatedScrollHandler( + { + onScroll: ({ contentOffset: { y } }) => { + scrollY.value = y + }, + onEndDrag: ({ contentOffset: { y } }) => { + if (!disableRefresh && !isFetching) { + if (y <= SEPARATION_Y_2) { + fetchingType.value = 2 + } else if (y <= SEPARATION_Y_1) { + fetchingType.value = 1 + } + } + } + }, + [isFetching] + ) + const androidRefreshControl = Platform.select({ android: { refreshControl: ( @@ -115,46 +133,40 @@ const Timeline: React.FC = ({ }) return ( - - } - ListEmptyComponent={} - ItemSeparatorComponent={ItemSeparatorComponent} - {...(isFetchingPreviousPage && { - maintainVisibleContentPosition: { minIndexForVisible: 0 } - })} - refreshing={isFetchingPreviousPage} - onRefresh={() => { - if (!disableRefresh && !isFetchingPreviousPage) { - queryClient.setQueryData | undefined>( - queryKey, - data => { - if (data?.pages[0] && data.pages[0].body.length === 0) { - return { - pages: data.pages.slice(1), - pageParams: data.pageParams.slice(1) - } - } else { - return data - } - } - ) - fetchPreviousPage() + <> + + } - }} - {...androidRefreshControl} - {...customProps} - /> + ListEmptyComponent={} + ItemSeparatorComponent={ItemSeparatorComponent} + maintainVisibleContentPosition={{ + minIndexForVisible: 0 + }} + {...androidRefreshControl} + {...customProps} + /> + ) } diff --git a/src/components/Timeline/Refresh.tsx b/src/components/Timeline/Refresh.tsx new file mode 100644 index 00000000..f5d5ad2a --- /dev/null +++ b/src/components/Timeline/Refresh.tsx @@ -0,0 +1,323 @@ +import haptics from '@components/haptics' +import Icon from '@components/Icon' +import { + QueryKeyTimeline, + TimelineData, + useTimelineQuery +} from '@utils/queryHooks/timeline' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { RefObject, useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList, Platform, StyleSheet, Text, View } from 'react-native' +import { Circle } from 'react-native-animated-spinkit' +import Animated, { + Extrapolate, + interpolate, + runOnJS, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated' +import { InfiniteData, useQueryClient } from 'react-query' + +export interface Props { + flRef: RefObject> + queryKey: QueryKeyTimeline + scrollY: Animated.SharedValue + fetchingType: Animated.SharedValue<0 | 1 | 2> + disableRefresh?: boolean +} + +const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5 +export const SEPARATION_Y_1 = -( + CONTAINER_HEIGHT / 2 + + StyleConstants.Font.Size.S / 2 +) +export const SEPARATION_Y_2 = -( + CONTAINER_HEIGHT * 1.5 + + StyleConstants.Font.Size.S / 2 +) + +const TimelineRefresh: React.FC = ({ + flRef, + queryKey, + scrollY, + fetchingType, + disableRefresh = false +}) => { + if (Platform.OS !== 'ios') { + return null + } + if (disableRefresh) { + return null + } + + const fetchingLatestIndex = useRef(0) + const refetchActive = useRef(false) + + const { + refetch, + isFetching, + isLoading, + fetchPreviousPage, + hasPreviousPage, + isFetchingNextPage + } = useTimelineQuery({ + ...queryKey[1], + options: { + getPreviousPageParam: firstPage => + firstPage?.links?.prev && { + min_id: firstPage.links.prev, + // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 + limit: '3' + }, + select: data => { + if (refetchActive.current) { + data.pageParams = [data.pageParams[0]] + data.pages = [data.pages[0]] + refetchActive.current = false + } + return data + }, + onSuccess: () => { + if (fetchingLatestIndex.current > 0) { + if (fetchingLatestIndex.current > 5) { + clearFirstPage() + fetchingLatestIndex.current = 0 + } else { + if (hasPreviousPage) { + fetchPreviousPage() + fetchingLatestIndex.current++ + } else { + clearFirstPage() + fetchingLatestIndex.current = 0 + } + } + } + } + } + }) + + const { t } = useTranslation('componentTimeline') + const { colors } = useTheme() + + const queryClient = useQueryClient() + const clearFirstPage = () => { + queryClient.setQueryData | undefined>( + queryKey, + data => { + if (data?.pages[0] && data.pages[0].body.length === 0) { + return { + pages: data.pages.slice(1), + pageParams: data.pageParams.slice(1) + } + } else { + return data + } + } + ) + } + const prepareRefetch = () => { + refetchActive.current = true + queryClient.setQueryData | undefined>( + queryKey, + data => { + if (data) { + data.pageParams = [undefined] + const newFirstPage: TimelineData = { body: [] } + for (let page of data.pages) { + // @ts-ignore + newFirstPage.body.push(...page.body) + if (newFirstPage.body.length > 10) break + } + data.pages = [newFirstPage] + } + + return data + } + ) + } + const callRefetch = async () => { + await refetch() + setTimeout(() => flRef.current?.scrollToOffset({ offset: 1 }), 50) + } + + const [textRight, setTextRight] = useState(0) + const arrowY = useAnimatedStyle(() => ({ + transform: [ + { + translateY: interpolate( + scrollY.value, + [0, SEPARATION_Y_1], + [ + -CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2, + CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2 + ], + Extrapolate.CLAMP + ) + } + ] + })) + const arrowTop = useAnimatedStyle(() => ({ + marginTop: + scrollY.value < SEPARATION_Y_2 + ? withTiming(CONTAINER_HEIGHT) + : withTiming(0) + })) + + const arrowStage = useSharedValue(0) + const onLayout = useCallback( + ({ nativeEvent }) => { + if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) { + setTextRight(nativeEvent.layout.x + nativeEvent.layout.width) + } + }, + [textRight] + ) + useAnimatedReaction( + () => { + if (isFetching) { + return false + } + switch (arrowStage.value) { + case 0: + if (scrollY.value < SEPARATION_Y_1) { + arrowStage.value = 1 + return true + } + return false + case 1: + if (scrollY.value < SEPARATION_Y_2) { + arrowStage.value = 2 + return true + } + if (scrollY.value > SEPARATION_Y_1) { + arrowStage.value = 0 + return false + } + return false + case 2: + if (scrollY.value > SEPARATION_Y_2) { + arrowStage.value = 1 + return false + } + return false + } + }, + data => { + if (data) { + runOnJS(haptics)('Light') + } + }, + [isFetching] + ) + const wrapperStartLatest = () => { + fetchingLatestIndex.current = 1 + } + + useAnimatedReaction( + () => { + return fetchingType.value + }, + data => { + fetchingType.value = 0 + switch (data) { + case 1: + runOnJS(wrapperStartLatest)() + runOnJS(clearFirstPage)() + runOnJS(fetchPreviousPage)() + break + case 2: + runOnJS(prepareRefetch)() + runOnJS(callRefetch)() + break + } + }, + [] + ) + + const headerPadding = useAnimatedStyle( + () => ({ + paddingTop: + fetchingLatestIndex.current !== 0 || + (isFetching && !isLoading && !isFetchingNextPage) + ? withTiming(StyleConstants.Spacing.M * 2.5) + : withTiming(0) + }), + [fetchingLatestIndex.current, isFetching, isFetchingNextPage, isLoading] + ) + + return ( + + + {isFetching ? ( + + + + ) : ( + <> + + + + } + /> + + + + + + )} + + + ) +} + +const styles = StyleSheet.create({ + base: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: CONTAINER_HEIGHT * 2, + alignItems: 'center' + }, + container1: { + flex: 1, + flexDirection: 'row', + height: CONTAINER_HEIGHT + }, + container2: { height: CONTAINER_HEIGHT, justifyContent: 'center' }, + explanation: { + fontSize: StyleConstants.Font.Size.S, + lineHeight: CONTAINER_HEIGHT + } +}) + +export default TimelineRefresh