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