import haptics from '@components/haptics' import Icon from '@components/Icon' import { InfiniteData, useQueryClient } from '@tanstack/react-query' import { PagedResponse } from '@utils/api/helpers' import { queryFunctionTimeline, QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { setAccountStorage } from '@utils/storage/actions' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { RefObject, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, Platform, Text, View } from 'react-native' import Animated, { Extrapolate, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated' export interface Props { flRef: RefObject> queryKey: QueryKeyTimeline fetchingActive: React.MutableRefObject scrollY: Animated.SharedValue fetchingType: Animated.SharedValue<0 | 1 | 2> disableRefresh?: boolean readMarker?: 'read_marker_following' } 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, fetchingActive, scrollY, fetchingType, disableRefresh = false, readMarker }) => { if (Platform.OS !== 'ios') { return null } if (disableRefresh) { return null } const PREV_PER_BATCH = 1 const prevActive = useRef(false) const prevCache = useRef<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>() const prevStatusId = useRef() const queryClient = useQueryClient() const { refetch, isRefetching } = useTimelineQuery({ ...queryKey[1] }) useDerivedValue(() => { if (prevActive.current || isRefetching) { fetchingActive.current = true } else { fetchingActive.current = false } }, [prevActive.current, isRefetching]) const { t } = useTranslation('componentTimeline') const { colors } = useTheme() 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) useAnimatedReaction( () => { if (fetchingActive.current) { 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') } }, [fetchingActive.current] ) const fetchAndScrolled = useSharedValue(false) const runFetchPrevious = async () => { if (prevActive.current) return const firstPage = queryClient.getQueryData< InfiniteData< PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]> > >(queryKey)?.pages[0] prevActive.current = true prevStatusId.current = firstPage?.body[0].id await queryFunctionTimeline({ queryKey, pageParam: firstPage?.links?.prev && { ...(firstPage.links.prev.isOffset ? { offset: firstPage.links.prev.id } : { min_id: firstPage.links.prev.id }) }, meta: {} }) .then(res => { if (!res.body.length) return queryClient.setQueryData< InfiniteData< PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]> > >(queryKey, old => { if (!old) return old prevCache.current = res.body.slice(0, -PREV_PER_BATCH) return { ...old, pages: [{ ...res, body: res.body.slice(-PREV_PER_BATCH) }, ...old.pages] } }) return res.body.length - PREV_PER_BATCH }) .then(async nextLength => { if (!nextLength) { prevActive.current = false return } for (let [index] of Array(Math.ceil(nextLength / PREV_PER_BATCH)).entries()) { if (!fetchAndScrolled.value && index < 3 && scrollY.value > 15) { fetchAndScrolled.value = true flRef.current?.scrollToOffset({ offset: scrollY.value - 15, animated: true }) } await new Promise(promise => setTimeout(promise, 32)) queryClient.setQueryData< InfiniteData< PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]> > >(queryKey, old => { if (!old) return old return { ...old, pages: old.pages.map((page, index) => { if (index === 0) { const insert = prevCache.current?.slice(-PREV_PER_BATCH) prevCache.current = prevCache.current?.slice(0, -PREV_PER_BATCH) if (insert) { return { ...page, body: [...insert, ...page.body] } } else { return page } } else { return page } }) } }) } prevActive.current = false }) } const runFetchLatest = async () => { queryClient.invalidateQueries(queryKey) if (readMarker) { setAccountStorage([{ key: readMarker, value: undefined }]) } await refetch() setTimeout(() => flRef.current?.scrollToOffset({ offset: 0 }), 50) } useAnimatedReaction( () => { return fetchingType.value }, data => { fetchingType.value = 0 switch (data) { case 1: runOnJS(runFetchPrevious)() return case 2: runOnJS(runFetchLatest)() return } }, [] ) return ( { if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) { setTextRight(nativeEvent.layout.x + nativeEvent.layout.width) } }} children={t('refresh.fetchPreviousPage')} /> } /> { if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) { setTextRight(nativeEvent.layout.x + nativeEvent.layout.width) } }} children={t('refresh.refetch')} /> ) } export default TimelineRefresh