diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index e01c509d..9f484300 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -2,7 +2,7 @@ name: Publish production on: push: branches: - - production + - main jobs: publish: runs-on: ubuntu-latest diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 046f7bd8..03a54558 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -152,7 +152,7 @@ lane :build do else puts("Release #{GITHUB_RELEASE} does not exist. Create new release as well as new native build.") build_ios - build_android + # build_android case ENVIRONMENT when "staging" github_release(prerelease: true) diff --git a/package.json b/package.json index 83d24c0f..d5bb0594 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,19 @@ { + "name": "tooot", + "versions": { + "native": "210201", + "major": 0, + "minor": 5, + "patch": 0, + "expo": "40.0.0" + }, + "description": "tooot app for Mastodon", + "author": "xmflsct ", + "license": "GPL-3.0-or-later", + "repository": { + "type": "git", + "url": "https://github.com/tooot-app/app.git" + }, "scripts": { "start": "react-native start", "android": "react-native run-android", @@ -65,7 +80,7 @@ "react-native-tab-view-viewpager-adapter": "^1.1.0", "react-native-toast-message": "^1.4.3", "react-native-unimodules": "~0.12.0", - "react-query": "^3.9.7", + "react-query": "^3.12.0", "react-redux": "^7.2.2", "react-timeago": "^5.2.0", "reconnecting-websocket": "^4.4.0", @@ -104,14 +119,5 @@ "react-navigation-stack": "^2.10.2", "react-test-renderer": "^16.13.1", "typescript": "~4.1.3" - }, - "private": true, - "name": "tooot", - "versions": { - "native": "210201", - "major": 0, - "minor": 5, - "patch": 0, - "expo": "40.0.0" } -} +} \ No newline at end of file diff --git a/src/@types/react-navigation.d.ts b/src/@types/react-navigation.d.ts index 0bfcc2ac..a9a01b8f 100644 --- a/src/@types/react-navigation.d.ts +++ b/src/@types/react-navigation.d.ts @@ -71,7 +71,7 @@ declare namespace Nav { 'Tab-Local': undefined 'Tab-Public': undefined 'Tab-Compose': undefined - 'Tab-Notifications': undefined + 'Tab-Notifications': { id?: Mastodon.Notification['id'] } 'Tab-Me': undefined } @@ -117,7 +117,7 @@ declare namespace Nav { title: Mastodon.List['title'] } 'Tab-Me-Settings': undefined - 'Tab-Me-Settings-Notification': undefined + 'Tab-Me-Settings-Push': undefined 'Tab-Me-Switch': undefined } & TabSharedStackParamList } diff --git a/src/App.tsx b/src/App.tsx index 513b5aac..3969d082 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,14 +17,16 @@ import { enableScreens } from 'react-native-screens' import { QueryClient, QueryClientProvider } from 'react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' +import push from './startup/push' -if (Platform.OS === 'android') { - LogBox.ignoreLogs(['Setting a timer for a long period of time']) -} +Platform.select({ + android: LogBox.ignoreLogs(['Setting a timer for a long period of time']) +}) dev() sentry() audio() +push() onlineStatus() log('log', 'react-query', 'initializing') diff --git a/src/Screens.tsx b/src/Screens.tsx index 6e42c7bc..48a676c3 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -15,16 +15,47 @@ import { getInstanceActive } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' import { themes } from '@utils/styles/themes' import * as Analytics from 'expo-firebase-analytics' -// import { addScreenshotListener } from 'expo-screen-capture' +import * as Notifications from 'expo-notifications' +import { addScreenshotListener } from 'expo-screen-capture' import React, { createRef, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Platform, StatusBar } from 'react-native' +import { Alert, Linking, Platform, StatusBar } from 'react-native' import { createNativeStackNavigator } from 'react-native-screens/native-stack' import Toast from 'react-native-toast-message' import { useDispatch, useSelector } from 'react-redux' +import * as Sentry from 'sentry-expo' const Stack = createNativeStackNavigator() +const linking = { + prefixes: ['tooot://', 'https://tooot.app'], + config: { + screens: { + 'Screen-Tabs': { + screens: { + 'Tab-Notifications': 'push/:id' + } + } + } + }, + subscribe (listener: (arg0: string) => any) { + const onReceiveURL = ({ url }: { url: string }) => listener(url) + Linking.addEventListener('url', onReceiveURL) + const subscription = Notifications.addNotificationResponseReceivedListener( + response => { + const url = response.notification.request.content.data.url + console.log(url) + url && typeof url === 'string' && listener(url) + } + ) + + return () => { + Linking.removeEventListener('url', onReceiveURL) + subscription.remove() + } + } +} + export interface Props { localCorrupt?: string } @@ -57,15 +88,15 @@ const Screens: React.FC = ({ localCorrupt }) => { // }, [isConnected, firstRender]) // Prevent screenshot alert - // useEffect(() => { - // const screenshotListener = addScreenshotListener(() => - // Alert.alert(t('screenshot.title'), t('screenshot.message'), [ - // { text: t('screenshot.button'), style: 'destructive' } - // ]) - // ) - // Platform.OS === 'ios' && screenshotListener - // return () => screenshotListener.remove() - // }, []) + useEffect(() => { + const screenshotListener = addScreenshotListener(() => + Alert.alert(t('screenshot.title'), t('screenshot.message'), [ + { text: t('screenshot.button'), style: 'destructive' } + ]) + ) + Platform.select({ ios: screenshotListener }) + return () => screenshotListener.remove() + }, []) // On launch display login credentials corrupt information useEffect(() => { @@ -127,6 +158,10 @@ const Screens: React.FC = ({ localCorrupt }) => { if (previousRouteName !== currentRouteName) { Analytics.setCurrentScreen(currentRouteName) + Sentry.Native.setContext('page', { + previous: previousRouteName, + current: currentRouteName + }) } routeNameRef.current = currentRouteName @@ -140,6 +175,7 @@ const Screens: React.FC = ({ localCorrupt }) => { theme={themes[mode]} onReady={navigationContainerOnReady} onStateChange={navigationContainerOnStateChange} + linking={linking} > = ({ localCorrupt }) => { /> - {Platform.OS === 'ios' ? ( - - ) : null} + {Platform.select({ + ios: + })} ) diff --git a/src/components/Menu/Row.tsx b/src/components/Menu/Row.tsx index a5c1c2b2..d3b3a45a 100644 --- a/src/components/Menu/Row.tsx +++ b/src/components/Menu/Row.tsx @@ -57,13 +57,9 @@ const MenuRow: React.FC = ({ return ( { - if (nativeEvent.state === State.ACTIVE) { - if (!loading) { - onPress && onPress() - } - } - }} + onHandlerStateChange={({ nativeEvent }) => + nativeEvent.state === State.ACTIVE && !loading && onPress && onPress() + } > @@ -82,11 +78,6 @@ const MenuRow: React.FC = ({ > {title} - {description ? ( - - {description} - - ) : null} @@ -94,21 +85,18 @@ const MenuRow: React.FC = ({ {content ? ( typeof content === 'string' ? ( - <> - - {content} - - {loading && !iconBack && loadingSpinkit} - + + {content} + ) : ( content ) @@ -119,23 +107,27 @@ const MenuRow: React.FC = ({ onValueChange={switchOnValueChange} disabled={switchDisabled} trackColor={{ true: theme.blue, false: theme.disabled }} + style={{ opacity: loading ? 0 : 1 }} /> ) : null} {iconBack ? ( - <> - - {loading && loadingSpinkit} - + ) : null} + {loading && loadingSpinkit} ) : null} + {description ? ( + + {description} + + ) : null} ) } @@ -147,9 +139,7 @@ const styles = StyleSheet.create({ core: { flex: 1, flexDirection: 'row', - alignItems: 'center', - paddingLeft: StyleConstants.Spacing.Global.PagePadding, - paddingRight: StyleConstants.Spacing.Global.PagePadding + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }, front: { flex: 2, @@ -174,7 +164,8 @@ const styles = StyleSheet.create({ }, description: { ...StyleConstants.FontStyle.S, - marginTop: StyleConstants.Spacing.XS + marginTop: StyleConstants.Spacing.XS, + paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }, content: { ...StyleConstants.FontStyle.M diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index cbbf722d..62be75b7 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,93 +1,61 @@ import ComponentSeparator from '@components/Separator' -import { useNavigation, useScrollToTop } from '@react-navigation/native' +import { useScrollToTop } from '@react-navigation/native' 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' -import { findIndex } from 'lodash' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, useCallback, useRef } from 'react' import { + FlatList, FlatListProps, Platform, RefreshControl, StyleSheet } from 'react-native' -import { FlatList } from 'react-native-gesture-handler' import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming + useAnimatedScrollHandler, + useSharedValue } from 'react-native-reanimated' -import { InfiniteData, useQueryClient } from 'react-query' -import { useSelector } from 'react-redux' -import haptics from './haptics' -import TimelineConversation from './Timeline/Conversation' -import TimelineDefault from './Timeline/Default' import TimelineEmpty from './Timeline/Empty' -import TimelineEnd from './Timeline/End' -import TimelineNotifications from './Timeline/Notifications' -import TimelineRefresh from './Timeline/Refresh' +import TimelineFooter from './Timeline/Footer' +import TimelineRefresh, { + SEPARATION_Y_1, + SEPARATION_Y_2 +} from './Timeline/Refresh' + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export interface Props { - page: App.Pages - hashtag?: Mastodon.Tag['name'] - list?: Mastodon.List['id'] - toot?: Mastodon.Status['id'] - rootQueryKey?: QueryKeyTimeline - account?: Mastodon.Account['id'] + flRef?: RefObject> + queryKey: QueryKeyTimeline disableRefresh?: boolean disableInfinity?: boolean - customProps?: Partial> + customProps: Partial> & + Pick, 'renderItem'> } const Timeline: React.FC = ({ - page, - hashtag, - list, - toot, - rootQueryKey, - account, + flRef: customFLRef, + queryKey, disableRefresh = false, disableInfinity = false, customProps }) => { const { theme } = useTheme() - // Update timeline when account switched - useSelector(getInstanceActive) - - const queryKeyParams = { - page, - ...(hashtag && { hashtag }), - ...(list && { list }), - ...(toot && { toot }), - ...(account && { account }) - } - - const queryKey: QueryKeyTimeline = ['Timeline', queryKeyParams] const { - status, data, refetch, - isSuccess, isFetching, isLoading, - hasPreviousPage, - fetchPreviousPage, - isFetchingPreviousPage, - hasNextPage, fetchNextPage, isFetchingNextPage } = useTimelineQuery({ - ...queryKeyParams, + ...queryKey[1], options: { - getPreviousPageParam: firstPage => - firstPage?.links?.prev && { - min_id: firstPage.links.prev, - // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 - limit: '5' - }, - + notifyOnChangeProps: Platform.select({ + ios: ['data', 'isFetching'], + android: ['data', 'isFetching', 'isLoading'] + }), getNextPageParam: lastPage => lastPage?.links?.next && { max_id: lastPage.links.next } } @@ -95,243 +63,87 @@ const Timeline: React.FC = ({ const flattenData = data?.pages ? data.pages.flatMap(d => [...d.body]) : [] - // Auto go back when toot page is empty - const navigation = useNavigation() - useEffect(() => { - if (toot && isSuccess && flattenData.length === 0) { - navigation.goBack() - } - }, [isSuccess, flattenData.length]) - // Toot page auto scroll to selected toot - const flRef = useRef>(null) - const scrolled = useRef(false) - useEffect(() => { - if (toot && isSuccess && !scrolled.current) { - scrolled.current = true - const pointer = findIndex(flattenData, ['id', toot]) - setTimeout(() => { - flRef.current?.scrollToIndex({ - index: pointer, - viewOffset: 100 - }) - }, 500) - } - }, [isSuccess, flattenData.length, scrolled]) - const onScrollToIndexFailed = useCallback(error => { - const offset = error.averageItemLength * error.index - flRef.current?.scrollToOffset({ offset }) - setTimeout( - () => - flRef.current?.scrollToIndex({ index: error.index, viewOffset: 100 }), - 350 - ) - }, []) - - const keyExtractor = useCallback(({ id }) => id, []) - const renderItem = useCallback( - ({ item }) => { - switch (page) { - case 'Conversations': - return ( - - ) - case 'Notifications': - return ( - - ) - default: - return ( - - ) - } - }, - [data?.pages[0]] - ) const ItemSeparatorComponent = useCallback( - ({ leadingItem }) => ( - - ), + ({ leadingItem }) => + queryKey[1].page === 'Toot' && queryKey[1].toot === leadingItem.id ? ( + + ) : ( + + ), [] ) - const flItemEmptyComponent = useMemo( - () => , - [status] - ) const onEndReached = useCallback( () => !disableInfinity && !isFetchingNextPage && fetchNextPage(), [isFetchingNextPage] ) - const ListFooterComponent = useMemo( - () => , - [hasNextPage] - ) - useScrollToTop(flRef) - const queryClient = useQueryClient() + const flRef = useRef(null) const scrollY = useSharedValue(0) - const [isFetchingLatest, setIsFetchingLatest] = useState(0) - useEffect(() => { - // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 - if (isFetchingLatest !== 0) { - if (!isFetchingPreviousPage) { - fetchPreviousPage() - setIsFetchingLatest(isFetchingLatest + 1) - } else { - if (isFetchingLatest === 8) { - setIsFetchingLatest(0) - if (data?.pages[0].body.length === 0) { - queryClient.setQueryData | undefined>( - queryKey, - data => { - if (data?.pages[0].body.length === 0) { - return { - pages: data.pages.slice(1), - pageParams: data.pageParams.slice(1) - } - } else { - return data - } - } - ) - } - } else { - if (data?.pages[0].body.length === 0) { - setIsFetchingLatest(0) - queryClient.setQueryData | undefined>( - queryKey, - data => { - if (data?.pages[0].body.length === 0) { - return { - pages: data.pages.slice(1), - pageParams: data.pageParams.slice(1) - } - } else { - return data - } - } - ) + 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 } } } - } - }, [isFetchingPreviousPage, isFetchingLatest, data?.pages[0].body]) - const onScroll = useCallback(({ nativeEvent }) => { - scrollY.value = nativeEvent.contentOffset.y - }, []) - const onResponderRelease = useCallback(() => { - if (!disableRefresh) { - const separation01 = -( - (StyleConstants.Spacing.M * 2.5) / 2 + - StyleConstants.Font.Size.S / 2 - ) - const separation02 = -( - StyleConstants.Spacing.M * 2.5 * 1.5 + - StyleConstants.Font.Size.S / 2 - ) - if ( - scrollY.value <= separation02 && - !isFetching && - isFetchingLatest === 0 - ) { - haptics('Light') - queryClient.setQueryData | undefined>( - queryKey, - data => { - if (data?.pages[0].body.length === 0) { - return { - pages: data.pages.slice(1), - pageParams: data.pageParams.slice(1) - } - } else { - return data - } - } - ) - refetch() - } else if ( - scrollY.value <= separation01 && - !isFetching && - isFetchingLatest === 0 - ) { - haptics('Light') - setIsFetchingLatest(1) - flRef.current?.scrollToOffset({ - animated: true, - offset: 1 - }) - } - } - }, [scrollY.value, isFetching, isFetchingLatest, disableRefresh]) - const headerPadding = useAnimatedStyle(() => { - return { - paddingTop: - isFetchingLatest !== 0 || (isFetching && !isLoading) - ? withTiming(StyleConstants.Spacing.M * 2.5) - : withTiming(0) - } - }, [isFetchingLatest, isFetching, isLoading]) - const ListHeaderComponent = useMemo( - () => , - [] + }, + [isFetching] ) - const androidRefreshControl = useMemo( - () => - Platform.OS === 'android' && { - refreshControl: ( - refetch()} - /> - ) - }, - [isFetching, isLoading] - ) + const androidRefreshControl = Platform.select({ + android: { + refreshControl: ( + refetch()} + /> + ) + } + }) + useScrollToTop(flRef) return ( <> - + } + ListEmptyComponent={} ItemSeparatorComponent={ItemSeparatorComponent} - {...(toot && isSuccess && { onScrollToIndexFailed })} maintainVisibleContentPosition={{ minIndexForVisible: 0 }} diff --git a/src/components/Timeline/Default.tsx b/src/components/Timeline/Default.tsx index c5cd1e89..fea7bb6c 100644 --- a/src/components/Timeline/Default.tsx +++ b/src/components/Timeline/Default.tsx @@ -41,10 +41,7 @@ const TimelineDefault: React.FC = ({ pinned }) => { const { theme } = useTheme() - const instanceAccount = useSelector( - getInstanceAccount, - (prev, next) => prev?.id === next?.id - ) + const instanceAccount = useSelector(getInstanceAccount, (prev, next) => true) const navigation = useNavigation< StackNavigationProp >() diff --git a/src/components/Timeline/Empty.tsx b/src/components/Timeline/Empty.tsx index 1d298403..afaa9376 100644 --- a/src/components/Timeline/Empty.tsx +++ b/src/components/Timeline/Empty.tsx @@ -1,72 +1,79 @@ import analytics from '@components/analytics' import Button from '@components/Button' import Icon from '@components/Icon' +import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, View } from 'react-native' import { Circle } from 'react-native-animated-spinkit' -import { QueryStatus } from 'react-query' export interface Props { - status: QueryStatus - refetch: () => void + queryKey: QueryKeyTimeline } -const TimelineEmpty: React.FC = ({ status, refetch }) => { - const { mode, theme } = useTheme() - const { t, i18n } = useTranslation('componentTimeline') +const TimelineEmpty = React.memo( + ({ queryKey }: Props) => { + const { status, refetch } = useTimelineQuery({ + ...queryKey[1], + options: { notifyOnChangeProps: ['status'] } + }) - const children = useMemo(() => { - switch (status) { - case 'loading': - return ( - - ) - case 'error': - return ( - <> - - - {t('empty.error.message')} - -