mirror of https://github.com/tooot-app/app
306 lines
8.3 KiB
TypeScript
306 lines
8.3 KiB
TypeScript
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<FlatList<any>>
|
|
queryKey: QueryKeyTimeline
|
|
fetchingActive: React.MutableRefObject<boolean>
|
|
scrollY: Animated.SharedValue<number>
|
|
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<Props> = ({
|
|
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<boolean>(false)
|
|
const prevCache = useRef<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>()
|
|
const prevStatusId = useRef<Mastodon.Status['id']>()
|
|
|
|
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,
|
|
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, 64))
|
|
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) {
|
|
page.body.unshift(...insert)
|
|
return page
|
|
} 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 (
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: CONTAINER_HEIGHT * 2,
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<View style={{ flex: 1, flexDirection: 'row', height: CONTAINER_HEIGHT }}>
|
|
<Text
|
|
style={{
|
|
fontSize: StyleConstants.Font.Size.S,
|
|
lineHeight: CONTAINER_HEIGHT,
|
|
color: colors.primaryDefault
|
|
}}
|
|
onLayout={({ nativeEvent }) => {
|
|
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
|
|
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
|
|
}
|
|
}}
|
|
children={t('refresh.fetchPreviousPage')}
|
|
/>
|
|
<Animated.View
|
|
style={[
|
|
{
|
|
position: 'absolute',
|
|
left: textRight + StyleConstants.Spacing.S
|
|
},
|
|
arrowY,
|
|
arrowTop
|
|
]}
|
|
children={
|
|
<Icon
|
|
name='ArrowLeft'
|
|
size={StyleConstants.Font.Size.M}
|
|
color={colors.primaryDefault}
|
|
/>
|
|
}
|
|
/>
|
|
</View>
|
|
<View style={{ height: CONTAINER_HEIGHT, justifyContent: 'center' }}>
|
|
<Text
|
|
style={{
|
|
fontSize: StyleConstants.Font.Size.S,
|
|
lineHeight: CONTAINER_HEIGHT,
|
|
color: colors.primaryDefault
|
|
}}
|
|
onLayout={({ nativeEvent }) => {
|
|
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
|
|
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
|
|
}
|
|
}}
|
|
children={t('refresh.refetch')}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
export default TimelineRefresh
|