diff --git a/src/components/Parse/Emojis.tsx b/src/components/Parse/Emojis.tsx index b09f6776..6e8df38b 100644 --- a/src/components/Parse/Emojis.tsx +++ b/src/components/Parse/Emojis.tsx @@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants' import { adaptiveScale } from '@utils/styles/scaling' import { useTheme } from '@utils/styles/ThemeManager' import React, { useMemo } from 'react' -import { StyleSheet } from 'react-native' +import { Platform, StyleSheet } from 'react-native' import FastImage from 'react-native-fast-image' import { useSelector } from 'react-redux' import validUrl from 'valid-url' @@ -51,7 +51,13 @@ const ParseEmojis = React.memo( image: { width: adaptedFontsize, height: adaptedFontsize, - transform: [{ translateY: -2 }] + ...(Platform.OS === 'ios' + ? { + transform: [{ translateY: -2 }] + } + : { + transform: [{ translateY: 1 }] + }) } }) }, [theme, adaptiveFontsize]) diff --git a/src/components/Parse/HTML.tsx b/src/components/Parse/HTML.tsx index d5a2dac7..c4c0ff62 100644 --- a/src/components/Parse/HTML.tsx +++ b/src/components/Parse/HTML.tsx @@ -13,7 +13,7 @@ import { adaptiveScale } from '@utils/styles/scaling' import { useTheme } from '@utils/styles/ThemeManager' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Pressable, View } from 'react-native' +import { Platform, Pressable, View } from 'react-native' import HTMLView from 'react-native-htmlview' import { useSelector } from 'react-redux' @@ -139,7 +139,13 @@ const renderNode = ({ name='ExternalLink' size={adaptedFontsize} style={{ - transform: [{ translateY: -2 }] + ...(Platform.OS === 'ios' + ? { + transform: [{ translateY: -2 }] + } + : { + transform: [{ translateY: 1 }] + }) }} /> ) : null} 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 diff --git a/src/components/Timeline/Shared/Attachment.tsx b/src/components/Timeline/Shared/Attachment.tsx index a4a4b670..0d2f5c40 100644 --- a/src/components/Timeline/Shared/Attachment.tsx +++ b/src/components/Timeline/Shared/Attachment.tsx @@ -8,11 +8,13 @@ import AttachmentVideo from '@components/Timeline/Shared/Attachment/Video' import { useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { RootStackParamList } from '@utils/navigation/navigators' +import { getInstanceAccount } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import layoutAnimation from '@utils/styles/layoutAnimation' import React, { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Pressable, View } from 'react-native' +import { useSelector } from 'react-redux' export interface Props { status: Pick @@ -22,7 +24,23 @@ const TimelineAttachment = React.memo( ({ status }: Props) => { const { t } = useTranslation('componentTimeline') - const [sensitiveShown, setSensitiveShown] = useState(status.sensitive) + const account = useSelector( + getInstanceAccount, + (prev, next) => + prev.preferences['reading:expand:media'] === + next.preferences['reading:expand:media'] + ) + const defaultSensitive = () => { + switch (account.preferences['reading:expand:media']) { + case 'show_all': + return false + case 'hide_all': + return true + default: + return status.sensitive + } + } + const [sensitiveShown, setSensitiveShown] = useState(defaultSensitive()) const imageUrls = useRef< RootStackParamList['Screen-ImagesViewer']['imageUrls'] @@ -151,7 +169,7 @@ const TimelineAttachment = React.memo( })} - {status.sensitive && + {defaultSensitive() && (sensitiveShown ? ( = ({ accessibleRefEmojis }) => { )} source={{ uri }} style={{ - width: 32, - height: 32, + width: 36, + height: 36, padding: StyleConstants.Spacing.S, margin: StyleConstants.Spacing.S }} @@ -119,8 +119,8 @@ const ComposeEmojis: React.FC = ({ accessibleRefEmojis }) => { )} source={{ uri }} style={{ - width: 32, - height: 32, + width: 36, + height: 36, padding: StyleConstants.Spacing.S, margin: StyleConstants.Spacing.S }} @@ -145,7 +145,7 @@ const ComposeEmojis: React.FC = ({ accessibleRefEmojis }) => { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-around', - height: 260 + height: 280 }} > - navigation: NativeStackNavigationProp< - RootStackParamList, - 'Screen-ImagesViewer' - > - currentIndex: number - imageUrls: RootStackParamList['Screen-ImagesViewer']['imageUrls'] - }) => { - const insets = useSafeAreaInsets() - const { mode, theme } = useTheme() - const { t } = useTranslation('screenImageViewer') - const { showActionSheetWithOptions } = useActionSheet() - - const onPress = useCallback(() => { - analytics('imageviewer_more_press') - showActionSheetWithOptions( - { - options: [ - t('content.options.save'), - t('content.options.share'), - t('content.options.cancel') - ], - cancelButtonIndex: 2, - userInterfaceStyle: mode - }, - async buttonIndex => { - switch (buttonIndex) { - case 0: - analytics('imageviewer_more_save_press') - saveImage({ messageRef, theme, image: imageUrls[currentIndex] }) - break - case 1: - analytics('imageviewer_more_share_press') - switch (Platform.OS) { - case 'ios': - await Share.share({ url: imageUrls[currentIndex].url }) - break - case 'android': - await Share.share({ message: imageUrls[currentIndex].url }) - break - } - break - } - } - ) - }, [currentIndex]) - - return ( - - navigation.goBack()} - /> - - - - ) - }, - (prev, next) => prev.currentIndex === next.currentIndex -) - const ScreenImagesViewer = ({ route: { params: { imageUrls, id } @@ -118,13 +30,51 @@ const ScreenImagesViewer = ({ return null } - const { theme } = useTheme() + const insets = useSafeAreaInsets() + + const { mode, theme } = useTheme() + const { t } = useTranslation('screenImageViewer') const initialIndex = imageUrls.findIndex(image => image.id === id) const [currentIndex, setCurrentIndex] = useState(initialIndex) const messageRef = useRef(null) + const { showActionSheetWithOptions } = useActionSheet() + const onPress = useCallback(() => { + analytics('imageviewer_more_press') + showActionSheetWithOptions( + { + options: [ + t('content.options.save'), + t('content.options.share'), + t('content.options.cancel') + ], + cancelButtonIndex: 2, + userInterfaceStyle: mode + }, + async buttonIndex => { + switch (buttonIndex) { + case 0: + analytics('imageviewer_more_save_press') + saveImage({ messageRef, theme, image: imageUrls[currentIndex] }) + break + case 1: + analytics('imageviewer_more_share_press') + switch (Platform.OS) { + case 'ios': + await Share.share({ url: imageUrls[currentIndex].url }) + break + case 'android': + await Share.share({ message: imageUrls[currentIndex].url }) + break + } + break + } + } + ) + }, [currentIndex]) + return (