From f5414412d4ec91ab2cc6b8559a6db3c803b1ce7e Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Mon, 8 Feb 2021 23:19:55 +0100 Subject: [PATCH] #18 Use websocket to constantly fetch new notifications. Also use flatlist item view to clear notification. --- package.json | 1 + src/@types/app.d.ts | 1 - src/@types/mastodon.d.ts | 21 ++ src/App.tsx | 4 +- src/Screens.tsx | 26 +-- src/api/websocket.ts | 60 +++++ src/components/Instance/Auth.tsx | 2 +- src/components/Separator.tsx | 1 + src/components/Timelines.tsx | 104 --------- src/components/Timelines/Timeline.tsx | 183 ++++++++------- .../Timelines/Timeline/Shared/Attachment.tsx | 1 + .../Timeline/Shared/Attachment/Video.tsx | 6 +- src/screens/ImagesViewer.tsx | 7 +- src/screens/Tabs.tsx | 71 ++---- src/screens/Tabs/Local.tsx | 61 ++--- src/screens/Tabs/Me.tsx | 219 +++++++++--------- src/screens/Tabs/Notifications.tsx | 79 +++++-- src/screens/Tabs/Public.tsx | 112 ++++++++- src/store.ts | 18 +- src/utils/queryHooks/timeline.ts | 8 - src/utils/slices/instancesSlice.ts | 22 +- yarn.lock | 5 + 22 files changed, 576 insertions(+), 436 deletions(-) create mode 100644 src/api/websocket.ts delete mode 100644 src/components/Timelines.tsx diff --git a/package.json b/package.json index 88b381df..0cfb7e35 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-query": "^3.6.0", "react-redux": "^7.2.2", "react-timeago": "^5.2.0", + "reconnecting-websocket": "^4.4.0", "redux-persist": "^6.0.0", "rn-placeholder": "^3.0.3", "sentry-expo": "^3.0.4", diff --git a/src/@types/app.d.ts b/src/@types/app.d.ts index 97de4948..959b393b 100644 --- a/src/@types/app.d.ts +++ b/src/@types/app.d.ts @@ -3,7 +3,6 @@ declare namespace App { | 'Following' | 'Local' | 'LocalPublic' - | 'RemotePublic' | 'Notifications' | 'Hashtag' | 'List' diff --git a/src/@types/mastodon.d.ts b/src/@types/mastodon.d.ts index 57ce762c..72a0ed0d 100644 --- a/src/@types/mastodon.d.ts +++ b/src/@types/mastodon.d.ts @@ -421,4 +421,25 @@ declare namespace Mastodon { url: string // history: types } + + type WebSocketStream = + | 'user' + | 'public' + | 'public:local' + | 'hashtag' + | 'hashtag:local' + | 'list' + | 'direct' + type WebSocket = + | { + stream: WebSocketStream[] + event: 'update' + payload: string // Status + } + | { stream: WebSocketStream[]; event: 'delete'; payload: Status['id'] } + | { + stream: WebSocketStream[] + event: 'notification' + payload: string // Notification + } } diff --git a/src/App.tsx b/src/App.tsx index 55498b1b..513b5aac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { ActionSheetProvider } from '@expo/react-native-action-sheet' import i18n from '@root/i18n/i18n' -import Index from '@root/Screens' +import Screens from '@root/Screens' import audio from '@root/startup/audio' import dev from '@root/startup/dev' import log from '@root/startup/log' @@ -78,7 +78,7 @@ const App: React.FC = () => { return ( - + ) diff --git a/src/Screens.tsx b/src/Screens.tsx index 617a83fb..accd365e 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -17,10 +17,10 @@ import { 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 { addScreenshotListener } from 'expo-screen-capture' import React, { createRef, useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, Platform, StatusBar } from 'react-native' +import { Platform, StatusBar } from 'react-native' import Toast from 'react-native-toast-message' import { createSharedElementStackNavigator } from 'react-navigation-shared-element' import { useDispatch, useSelector } from 'react-redux' @@ -33,7 +33,7 @@ export interface Props { export const navigationRef = createRef() -const Index: React.FC = ({ localCorrupt }) => { +const Screens: React.FC = ({ localCorrupt }) => { const { t } = useTranslation('common') const dispatch = useDispatch() const localActiveIndex = useSelector(getLocalActiveIndex) @@ -59,15 +59,15 @@ const Index: 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.OS === 'ios' && screenshotListener + // return () => screenshotListener.remove() + // }, []) // On launch display login credentials corrupt information useEffect(() => { @@ -234,4 +234,4 @@ const Index: React.FC = ({ localCorrupt }) => { ) } -export default React.memo(Index, () => true) +export default React.memo(Screens, () => true) diff --git a/src/api/websocket.ts b/src/api/websocket.ts new file mode 100644 index 00000000..c9e62776 --- /dev/null +++ b/src/api/websocket.ts @@ -0,0 +1,60 @@ +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { + getLocalInstance, + updateLocalNotification +} from '@utils/slices/instancesSlice' +import { useEffect, useRef } from 'react' +import { InfiniteData, useQueryClient } from 'react-query' +import { useDispatch, useSelector } from 'react-redux' +import ReconnectingWebSocket from 'reconnecting-websocket' + +const useWebsocket = ({ + stream, + event +}: { + stream: Mastodon.WebSocketStream + event: 'update' | 'delete' | 'notification' +}) => { + const queryClient = useQueryClient() + const dispatch = useDispatch() + const localInstance = useSelector(getLocalInstance) + + const rws = useRef() + useEffect(() => { + if (!localInstance) { + return + } + rws.current = new ReconnectingWebSocket( + `${localInstance.urls.streaming_api}/api/v1/streaming?stream=${stream}&access_token=${localInstance.token}` + ) + rws.current.addEventListener('message', ({ data }) => { + const message: Mastodon.WebSocket = JSON.parse(data) + if (message.event === event) { + switch (message.event) { + case 'notification': + const payload: Mastodon.Notification = JSON.parse(message.payload) + dispatch( + updateLocalNotification({ latestTime: payload.created_at }) + ) + const queryKey: QueryKeyTimeline = [ + 'Timeline', + { page: 'Notifications' } + ] + const queryData = queryClient.getQueryData(queryKey) + queryData !== undefined && + queryClient.setQueryData< + InfiniteData | undefined + >(queryKey, old => { + if (old) { + old.pages[0].unshift(payload) + return old + } + }) + break + } + } + }) + }, [localInstance?.urls.streaming_api, localInstance?.token]) +} + +export default useWebsocket diff --git a/src/components/Instance/Auth.tsx b/src/components/Instance/Auth.tsx index d63f5769..a6a07a7f 100644 --- a/src/components/Instance/Auth.tsx +++ b/src/components/Instance/Auth.tsx @@ -65,7 +65,7 @@ const InstanceAuth = React.memo( localAddInstance({ url: instanceDomain, token: accessToken, - uri: instance.uri, + instance, max_toot_chars: instance.max_toot_chars, appData }) diff --git a/src/components/Separator.tsx b/src/components/Separator.tsx index d2f7a0a3..2b10db67 100644 --- a/src/components/Separator.tsx +++ b/src/components/Separator.tsx @@ -15,6 +15,7 @@ const ComponentSeparator = React.memo( return ( () - -const Timelines: React.FC = () => { - const { t, i18n } = useTranslation() - const pages: { title: string; page: App.Pages }[] = [ - { title: t('public:heading.segments.left'), page: 'LocalPublic' }, - { title: t('public:heading.segments.right'), page: 'Local' } - ] - - const navigation = useNavigation() - const localActiveIndex = useSelector(getLocalActiveIndex) - - const onPressSearch = useCallback(() => { - analytics('search_tap', { page: pages[segment].page }) - navigation.navigate('Tab-Public', { screen: 'Tab-Shared-Search' }) - }, []) - - const routes = pages.map(p => ({ key: p.page })) - - const renderScene = useCallback( - ({ - route - }: { - route: { - key: App.Pages - } - }) => { - return localActiveIndex !== null && - }, - [localActiveIndex] - ) - - const { mode } = useTheme() - const [segment, setSegment] = useState(0) - const screenOptions = useMemo(() => { - if (localActiveIndex !== null) { - return { - headerCenter: () => ( - p.title)} - selectedIndex={segment} - onChange={({ nativeEvent }) => - setSegment(nativeEvent.selectedSegmentIndex) - } - style={styles.segmentsContainer} - /> - ), - headerRight: () => ( - - ) - } - } - }, [localActiveIndex, mode, segment, i18n.language]) - - const renderPager = useCallback(props => , []) - - return ( - - - {() => ( - null} - onIndexChange={index => setSegment(index)} - navigationState={{ index: segment, routes }} - initialLayout={{ width: Dimensions.get('screen').width }} - /> - )} - - - {sharedScreens(Stack as any)} - - ) -} - -const styles = StyleSheet.create({ - segmentsContainer: { - flexBasis: '65%' - } -}) - -export default React.memo(Timelines, () => true) diff --git a/src/components/Timelines/Timeline.tsx b/src/components/Timelines/Timeline.tsx index 6a9b47be..f6727e86 100644 --- a/src/components/Timelines/Timeline.tsx +++ b/src/components/Timelines/Timeline.tsx @@ -1,23 +1,23 @@ 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 { updateLocalNotification } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { findIndex } from 'lodash' import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { - FlatListProps, - Platform, - RefreshControl, - StyleSheet -} from 'react-native' +import { FlatListProps, StyleSheet } from 'react-native' import { FlatList } from 'react-native-gesture-handler' -import { useDispatch } from 'react-redux' +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated' +import { InfiniteData, useQueryClient } from 'react-query' 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' export interface Props { page: App.Pages @@ -55,12 +55,25 @@ const Timeline: React.FC = ({ isSuccess, isFetching, isLoading, + hasPreviousPage, + fetchPreviousPage, + isFetchingPreviousPage, hasNextPage, fetchNextPage, isFetchingNextPage } = useTimelineQuery({ ...queryKeyParams, options: { + getPreviousPageParam: firstPage => { + return Array.isArray(firstPage) && firstPage.length + ? { + direction: 'prev', + id: firstPage[0].last_status + ? firstPage[0].last_status.id + : firstPage[0].id + } + : undefined + }, getNextPageParam: lastPage => { return Array.isArray(lastPage) && lastPage.length ? { @@ -76,25 +89,6 @@ const Timeline: React.FC = ({ const flattenData = data?.pages ? data.pages.flatMap(d => [...d]) : [] - // Clear unread notification badge - const dispatch = useDispatch() - const navigation = useNavigation() - useEffect(() => { - const unsubscribe = navigation.addListener('focus', props => { - if (props.target && props.target.includes('Tab-Notifications-Root')) { - if (flattenData.length) { - dispatch( - updateLocalNotification({ - latestTime: (flattenData[0] as Mastodon.Notification).created_at - }) - ) - } - } - }) - - return unsubscribe - }, [navigation, flattenData]) - const flRef = useRef>(null) const scrolled = useRef(false) useEffect(() => { @@ -122,10 +116,6 @@ const Timeline: React.FC = ({ ) @@ -152,40 +142,27 @@ const Timeline: React.FC = ({ () => !disableInfinity && !isFetchingNextPage && fetchNextPage(), [isFetchingNextPage] ) - const ListFooterComponent = useCallback( + const prevId = useSharedValue(null) + const headerPadding = useAnimatedStyle(() => { + if (hasPreviousPage) { + if (isFetchingPreviousPage) { + return { paddingTop: withTiming(StyleConstants.Spacing.XL) } + } else { + return { paddingTop: withTiming(0) } + } + } else { + return { paddingTop: withTiming(0) } + } + }, [hasPreviousPage, isFetchingPreviousPage]) + const ListHeaderComponent = useMemo( + () => , + [] + ) + const ListFooterComponent = useMemo( () => , [hasNextPage] ) - const isSwipeDown = useRef(false) - const refreshControl = useMemo( - () => ( - { - isSwipeDown.current = true - refetch() - }} - /> - ), - [isSwipeDown.current, isFetching, isFetchingNextPage, isLoading] - ) - - useEffect(() => { - if (!isFetching) { - isSwipeDown.current = false - } - }, [isFetching]) - const onScrollToIndexFailed = useCallback(error => { const offset = error.averageItemLength * error.index flRef.current?.scrollToOffset({ offset }) @@ -197,30 +174,68 @@ const Timeline: React.FC = ({ }, []) useScrollToTop(flRef) + const queryClient = useQueryClient() + const scrollY = useSharedValue(0) + const onScroll = useCallback( + ({ nativeEvent }) => (scrollY.value = nativeEvent.contentOffset.y), + [] + ) + const onResponderRelease = useCallback(() => { + if ( + scrollY.value <= -StyleConstants.Spacing.XL && + !isFetchingPreviousPage && + !disableRefresh + ) { + queryClient.setQueryData | undefined>( + queryKey, + data => { + if (data?.pages[0].length === 0) { + if (data.pages[1]) { + prevId.value = data.pages[1][0].id + } + return { + pages: data.pages.slice(1), + pageParams: data.pageParams.slice(1) + } + } else { + prevId.value = data?.pages[0][0].id + return data + } + } + ) + // https://github.com/facebook/react-native/issues/25239#issuecomment-731100372 + fetchPreviousPage() + flRef.current?.scrollToOffset({ animated: true, offset: 1 }) + } + }, [scrollY.value, isFetchingPreviousPage, disableRefresh]) return ( - + <> + + + ) } diff --git a/src/components/Timelines/Timeline/Shared/Attachment.tsx b/src/components/Timelines/Timeline/Shared/Attachment.tsx index 3fdd143a..d7a2fcce 100644 --- a/src/components/Timelines/Timeline/Shared/Attachment.tsx +++ b/src/components/Timelines/Timeline/Shared/Attachment.tsx @@ -85,6 +85,7 @@ const TimelineAttachment: React.FC = ({ status }) => { index={index} sensitiveShown={sensitiveShown} video={attachment} + gifv /> ) case 'audio': diff --git a/src/components/Timelines/Timeline/Shared/Attachment/Video.tsx b/src/components/Timelines/Timeline/Shared/Attachment/Video.tsx index df0f218f..9ebd2f81 100644 --- a/src/components/Timelines/Timeline/Shared/Attachment/Video.tsx +++ b/src/components/Timelines/Timeline/Shared/Attachment/Video.tsx @@ -12,13 +12,15 @@ export interface Props { index: number sensitiveShown: boolean video: Mastodon.AttachmentVideo | Mastodon.AttachmentGifv + gifv?: boolean } const AttachmentVideo: React.FC = ({ total, index, sensitiveShown, - video + video, + gifv = false }) => { const videoPlayer = useRef