Use websocket to constantly fetch new notifications. Also use flatlist item view to clear notification.
This commit is contained in:
Zhiyuan Zheng 2021-02-08 23:19:55 +01:00
parent 01d4e6a5b9
commit f5414412d4
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
22 changed files with 576 additions and 436 deletions

View File

@ -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",

1
src/@types/app.d.ts vendored
View File

@ -3,7 +3,6 @@ declare namespace App {
| 'Following'
| 'Local'
| 'LocalPublic'
| 'RemotePublic'
| 'Notifications'
| 'Hashtag'
| 'List'

View File

@ -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
}
}

View File

@ -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 (
<ActionSheetProvider>
<ThemeManager>
<Index localCorrupt={localCorrupt} />
<Screens localCorrupt={localCorrupt} />
</ThemeManager>
</ActionSheetProvider>
)

View File

@ -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<NavigationContainerRef>()
const Index: React.FC<Props> = ({ localCorrupt }) => {
const Screens: React.FC<Props> = ({ localCorrupt }) => {
const { t } = useTranslation('common')
const dispatch = useDispatch()
const localActiveIndex = useSelector(getLocalActiveIndex)
@ -59,15 +59,15 @@ const Index: React.FC<Props> = ({ 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<Props> = ({ localCorrupt }) => {
)
}
export default React.memo(Index, () => true)
export default React.memo(Screens, () => true)

60
src/api/websocket.ts Normal file
View File

@ -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<ReconnectingWebSocket>()
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<Mastodon.Notification[]> | undefined
>(queryKey, old => {
if (old) {
old.pages[0].unshift(payload)
return old
}
})
break
}
}
})
}, [localInstance?.urls.streaming_api, localInstance?.token])
}
export default useWebsocket

View File

@ -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
})

View File

@ -15,6 +15,7 @@ const ComponentSeparator = React.memo(
return (
<View
style={{
backgroundColor: theme.background,
borderTopColor: theme.border,
borderTopWidth: StyleSheet.hairlineWidth,
marginLeft:

View File

@ -1,104 +0,0 @@
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timelines/Timeline'
import SegmentedControl from '@react-native-community/segmented-control'
import { useNavigation } from '@react-navigation/native'
import sharedScreens from '@screens/Tabs/Shared/sharedScreens'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions, StyleSheet } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { TabView } from 'react-native-tab-view'
import ViewPagerAdapter from 'react-native-tab-view-viewpager-adapter'
import { useSelector } from 'react-redux'
import analytics from './analytics'
const Stack = createNativeStackNavigator<Nav.TabPublicStackParamList>()
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 && <Timeline page={route.key} />
},
[localActiveIndex]
)
const { mode } = useTheme()
const [segment, setSegment] = useState(0)
const screenOptions = useMemo(() => {
if (localActiveIndex !== null) {
return {
headerCenter: () => (
<SegmentedControl
appearance={mode}
values={pages.map(p => p.title)}
selectedIndex={segment}
onChange={({ nativeEvent }) =>
setSegment(nativeEvent.selectedSegmentIndex)
}
style={styles.segmentsContainer}
/>
),
headerRight: () => (
<HeaderRight content='Search' onPress={onPressSearch} />
)
}
}
}, [localActiveIndex, mode, segment, i18n.language])
const renderPager = useCallback(props => <ViewPagerAdapter {...props} />, [])
return (
<Stack.Navigator
screenOptions={{ headerHideShadow: true, headerTopInsetEnabled: false }}
>
<Stack.Screen name='Screen-Remote-Root' options={screenOptions}>
{() => (
<TabView
lazy
swipeEnabled
renderPager={renderPager}
renderScene={renderScene}
renderTabBar={() => null}
onIndexChange={index => setSegment(index)}
navigationState={{ index: segment, routes }}
initialLayout={{ width: Dimensions.get('screen').width }}
/>
)}
</Stack.Screen>
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
}
const styles = StyleSheet.create({
segmentsContainer: {
flexBasis: '65%'
}
})
export default React.memo(Timelines, () => true)

View File

@ -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<Props> = ({
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<Props> = ({
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<FlatList<any>>(null)
const scrolled = useRef(false)
useEffect(() => {
@ -122,10 +116,6 @@ const Timeline: React.FC<Props> = ({
<TimelineDefault
item={item}
queryKey={queryKey}
{...(queryKey[1].page === 'RemotePublic' && {
disableDetails: true,
disableOnPress: true
})}
{...(toot === item.id && { highlighted: true })}
/>
)
@ -152,40 +142,27 @@ const Timeline: React.FC<Props> = ({
() => !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(
() => <Animated.View style={headerPadding} />,
[]
)
const ListFooterComponent = useMemo(
() => <TimelineEnd hasNextPage={!disableInfinity ? hasNextPage : false} />,
[hasNextPage]
)
const isSwipeDown = useRef(false)
const refreshControl = useMemo(
() => (
<RefreshControl
{...(Platform.OS === 'android' && { enabled: true })}
refreshing={
Platform.OS === 'android'
? (isSwipeDown.current && isFetching && !isFetchingNextPage) ||
isLoading
: isSwipeDown.current &&
isFetching &&
!isFetchingNextPage &&
!isLoading
}
onRefresh={() => {
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<Props> = ({
}, [])
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<InfiniteData<any> | 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 (
<FlatList
ref={flRef}
windowSize={8}
data={flattenData}
initialNumToRender={3}
maxToRenderPerBatch={3}
style={styles.flatList}
renderItem={renderItem}
onEndReached={onEndReached}
keyExtractor={keyExtractor}
onEndReachedThreshold={0.75}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={flItemEmptyComponent}
{...(!disableRefresh && { refreshControl })}
ItemSeparatorComponent={ItemSeparatorComponent}
{...(toot && isSuccess && { onScrollToIndexFailed })}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 1
}}
{...customProps}
/>
<>
<TimelineRefresh isLoading={isLoading} disable={disableRefresh} />
<FlatList
onScroll={onScroll}
onResponderRelease={onResponderRelease}
ref={flRef}
windowSize={8}
data={flattenData}
initialNumToRender={3}
maxToRenderPerBatch={3}
style={styles.flatList}
renderItem={renderItem}
onEndReached={onEndReached}
keyExtractor={keyExtractor}
onEndReachedThreshold={0.75}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={flItemEmptyComponent}
ItemSeparatorComponent={ItemSeparatorComponent}
{...(toot && isSuccess && { onScrollToIndexFailed })}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}
{...customProps}
/>
</>
)
}

View File

@ -85,6 +85,7 @@ const TimelineAttachment: React.FC<Props> = ({ status }) => {
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':

View File

@ -12,13 +12,15 @@ export interface Props {
index: number
sensitiveShown: boolean
video: Mastodon.AttachmentVideo | Mastodon.AttachmentGifv
gifv?: boolean
}
const AttachmentVideo: React.FC<Props> = ({
total,
index,
sensitiveShown,
video
video,
gifv = false
}) => {
const videoPlayer = useRef<Video>(null)
const [videoLoading, setVideoLoading] = useState(false)
@ -92,7 +94,7 @@ const AttachmentVideo: React.FC<Props> = ({
}}
/>
) : null
) : (
) : gifv ? null : (
<Button
round
overlay

View File

@ -100,11 +100,8 @@ const ScreenImagesViewer = React.memo(
useLayoutEffect(
() =>
navigation.setOptions({
headerTitle: () => (
<HeaderCenter
content={`${currentIndex + 1} / ${imageUrls.length}`}
/>
),
headerTitle: `${currentIndex + 1} / ${imageUrls.length}`,
headerTintColor: theme.primaryOverlay,
headerRight: () => (
<HeaderRight
content='MoreHorizontal'

View File

@ -1,3 +1,4 @@
import useWebsocket from '@api/websocket'
import haptics from '@components/haptics'
import Icon from '@components/Icon'
import {
@ -6,17 +7,17 @@ import {
} from '@react-navigation/bottom-tabs'
import { NavigatorScreenParams } from '@react-navigation/native'
import { StackScreenProps } from '@react-navigation/stack'
import { useTimelineQuery } from '@utils/queryHooks/timeline'
import {
getLocalAccount,
getLocalActiveIndex,
getLocalInstances,
getLocalNotification
} from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo } from 'react'
import { Platform } from 'react-native'
import FastImage from 'react-native-fast-image'
import { useDispatch, useSelector } from 'react-redux'
import { useSelector } from 'react-redux'
import TabLocal from './Tabs/Local'
import TabMe from './Tabs/Me'
import TabNotifications from './Tabs/Notifications'
@ -39,7 +40,6 @@ const Tab = createBottomTabNavigator<Nav.ScreenTabsStackParamList>()
const ScreenTabs: React.FC<ScreenTabsProp> = ({ navigation }) => {
const { theme } = useTheme()
const dispatch = useDispatch()
const localActiveIndex = useSelector(getLocalActiveIndex)
const localAccount = useSelector(getLocalAccount)
@ -134,53 +134,8 @@ const ScreenTabs: React.FC<ScreenTabsProp> = ({ navigation }) => {
)
// On launch check if there is any unread noficiations
const queryNotification = useTimelineQuery({
page: 'Notifications',
options: {
enabled: localActiveIndex !== null ? true : false,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true
}
})
const prevNotification = useSelector(getLocalNotification)
const notificationsOptions = useMemo(() => {
const badge = {
show: {
tabBarBadge: '',
tabBarBadgeStyle: {
transform: [{ scale: 0.5 }],
backgroundColor: theme.red
}
},
hide: {
tabBarBadgeStyle: {
transform: [{ scale: 0.5 }],
backgroundColor: theme.red
}
}
}
const flattenData = queryNotification.data?.pages.flatMap(d => [...d])
const latestNotificationTime = flattenData?.length
? (flattenData[0] as Mastodon.Notification).created_at
: undefined
if (prevNotification?.latestTime) {
if (
latestNotificationTime &&
new Date(prevNotification.latestTime) < new Date(latestNotificationTime)
) {
return badge.show
} else {
return badge.hide
}
} else {
if (latestNotificationTime) {
return badge.show
} else {
return badge.hide
}
}
}, [prevNotification, queryNotification.data?.pages])
useWebsocket({ stream: 'user', event: 'notification' })
const localNotification = useSelector(getLocalNotification)
return (
<Tab.Navigator
@ -203,7 +158,19 @@ const ScreenTabs: React.FC<ScreenTabsProp> = ({ navigation }) => {
name='Tab-Notifications'
component={TabNotifications}
listeners={notificationsListeners}
options={notificationsOptions}
options={{
tabBarBadge: localNotification?.latestTime
? !localNotification.readTime ||
new Date(localNotification.readTime) <
new Date(localNotification.latestTime)
? ''
: undefined
: undefined,
tabBarBadgeStyle: {
transform: [{ scale: 0.5 }],
backgroundColor: theme.red
}
}}
/>
<Tab.Screen name='Tab-Me' component={TabMe} />
</Tab.Navigator>

View File

@ -4,7 +4,7 @@ import Timeline from '@components/Timelines/Timeline'
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'
import { ScreenTabsParamList } from '@screens/Tabs'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import React, { useCallback } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
@ -23,36 +23,43 @@ const TabLocal = React.memo(
const { t } = useTranslation('local')
const localActiveIndex = useSelector(getLocalActiveIndex)
const onPressSearch = useCallback(() => {
analytics('search_tap', { page: 'Local' })
navigation.navigate('Tab-Local', { screen: 'Tab-Shared-Search' })
}, [])
const screenOptions = useMemo(
() => ({
headerHideShadow: true,
headerTopInsetEnabled: false
}),
[]
)
const screenOptionsRoot = useMemo(
() => ({
headerTitle: t('heading'),
...(Platform.OS === 'android' && {
headerCenter: () => <HeaderCenter content={t('heading')} />
}),
headerRight: () => (
<HeaderRight
content='Search'
onPress={() => {
analytics('search_tap', { page: 'Local' })
navigation.navigate('Tab-Local', { screen: 'Tab-Shared-Search' })
}}
/>
)
}),
[]
)
const children = useCallback(
() => (localActiveIndex !== null ? <Timeline page='Following' /> : null),
[]
)
return (
<Stack.Navigator
screenOptions={{
headerLeft: () => null,
headerTitle: t('heading'),
...(Platform.OS === 'android' && {
headerCenter: () => <HeaderCenter content={t('heading')} />
}),
headerHideShadow: true,
headerTopInsetEnabled: false
}}
>
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name='Tab-Local-Root'
options={{
headerRight: () => (
<HeaderRight content='Search' onPress={onPressSearch} />
)
}}
>
{() =>
localActiveIndex !== null ? <Timeline page='Following' /> : null
}
</Stack.Screen>
options={screenOptionsRoot}
children={children}
/>
{sharedScreens(Stack as any)}
</Stack.Navigator>
)

View File

@ -15,113 +15,120 @@ import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const Stack = createNativeStackNavigator<Nav.TabMeStackParamList>()
const TabMe: React.FC = () => {
const { t } = useTranslation()
const TabMe = React.memo(
() => {
const { t } = useTranslation()
return (
<Stack.Navigator
screenOptions={{ headerHideShadow: true, headerTopInsetEnabled: false }}
>
<Stack.Screen
name='Tab-Me-Root'
component={ScreenMeRoot}
options={{
headerTranslucent: true,
headerStyle: { backgroundColor: 'rgba(255, 255, 255, 0)' },
headerCenter: () => null
}}
/>
<Stack.Screen
name='Tab-Me-Bookmarks'
component={ScreenMeBookmarks}
options={({ navigation }: any) => ({
headerTitle: t('meBookmarks:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meBookmarks:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Conversations'
component={ScreenMeConversations}
options={({ navigation }: any) => ({
headerTitle: t('meConversations:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meConversations:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Favourites'
component={ScreenMeFavourites}
options={({ navigation }: any) => ({
headerTitle: t('meFavourites:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meFavourites:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Lists'
component={ScreenMeLists}
options={({ navigation }: any) => ({
headerTitle: t('meLists:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => <HeaderCenter content={t('meLists:heading')} />
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Lists-List'
component={ScreenMeListsList}
options={({ route, navigation }: any) => ({
headerTitle: t('meListsList:heading', { list: route.params.title }),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter
content={t('meListsList:heading', { list: route.params.title })}
/>
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Settings'
component={ScreenMeSettings}
options={({ navigation }: any) => ({
headerTitle: t('meSettings:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meSettings:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Switch'
component={ScreenMeSwitch}
options={({ navigation }: any) => ({
stackPresentation: 'modal',
headerShown: false,
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
return (
<Stack.Navigator
screenOptions={{ headerHideShadow: true, headerTopInsetEnabled: false }}
>
<Stack.Screen
name='Tab-Me-Root'
component={ScreenMeRoot}
options={{
headerTranslucent: true,
headerStyle: { backgroundColor: 'rgba(255, 255, 255, 0)' },
headerCenter: () => null
}}
/>
<Stack.Screen
name='Tab-Me-Bookmarks'
component={ScreenMeBookmarks}
options={({ navigation }: any) => ({
headerTitle: t('meBookmarks:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meBookmarks:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Conversations'
component={ScreenMeConversations}
options={({ navigation }: any) => ({
headerTitle: t('meConversations:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meConversations:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Favourites'
component={ScreenMeFavourites}
options={({ navigation }: any) => ({
headerTitle: t('meFavourites:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meFavourites:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Lists'
component={ScreenMeLists}
options={({ navigation }: any) => ({
headerTitle: t('meLists:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meLists:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Lists-List'
component={ScreenMeListsList}
options={({ route, navigation }: any) => ({
headerTitle: t('meListsList:heading', { list: route.params.title }),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter
content={t('meListsList:heading', {
list: route.params.title
})}
/>
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Settings'
component={ScreenMeSettings}
options={({ navigation }: any) => ({
headerTitle: t('meSettings:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meSettings:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Tab-Me-Switch'
component={ScreenMeSwitch}
options={({ navigation }: any) => ({
stackPresentation: 'modal',
headerShown: false,
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
}
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
},
() => true
)
export default TabMe

View File

@ -1,23 +1,22 @@
import { HeaderCenter } from '@components/Header'
import Timeline from '@components/Timelines/Timeline'
import sharedScreens from '@screens/Tabs/Shared/sharedScreens'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import React from 'react'
import { updateLocalNotification } from '@utils/slices/instancesSlice'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { Platform, ViewToken } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { useSelector } from 'react-redux'
import { useDispatch } from 'react-redux'
const Stack = createNativeStackNavigator<Nav.TabNotificationsStackParamList>()
const TabNotifications: React.FC = () => {
const { t } = useTranslation()
const localActiveIndex = useSelector(getLocalActiveIndex)
const TabNotifications = React.memo(
() => {
const { t } = useTranslation()
const dispatch = useDispatch()
return (
<Stack.Navigator
screenOptions={{
headerLeft: () => null,
const screenOptions = useMemo(
() => ({
headerTitle: t('notifications:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
@ -26,17 +25,53 @@ const TabNotifications: React.FC = () => {
}),
headerHideShadow: true,
headerTopInsetEnabled: false
}}
>
<Stack.Screen name='Tab-Notifications-Root'>
{() =>
localActiveIndex !== null ? <Timeline page='Notifications' /> : null
}
</Stack.Screen>
}),
[]
)
const children = useCallback(
({ navigation }) => (
<Timeline
page='Notifications'
customProps={{
viewabilityConfigCallbackPairs: [
{
onViewableItemsChanged: ({
viewableItems
}: {
viewableItems: ViewToken[]
}) => {
if (
navigation.isFocused() &&
viewableItems.length &&
viewableItems[0].index === 0
) {
dispatch(
updateLocalNotification({
readTime: viewableItems[0].item.created_at
})
)
}
},
viewabilityConfig: {
minimumViewTime: 100,
itemVisiblePercentThreshold: 60
}
}
]
}}
/>
),
[]
)
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
}
return (
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen name='Tab-Notifications-Root' children={children} />
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
},
() => true
)
export default TabNotifications

View File

@ -1,11 +1,117 @@
import Timelines from '@components/Timelines'
import React from 'react'
import analytics from '@components/analytics'
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timelines/Timeline'
import SegmentedControl from '@react-native-community/segmented-control'
import { useNavigation } from '@react-navigation/native'
import sharedScreens from '@screens/Tabs/Shared/sharedScreens'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions, StyleSheet } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { TabView } from 'react-native-tab-view'
import ViewPagerAdapter from 'react-native-tab-view-viewpager-adapter'
import { useSelector } from 'react-redux'
const Stack = createNativeStackNavigator<Nav.TabPublicStackParamList>()
const TabPublic = React.memo(
() => {
return <Timelines />
const { t, i18n } = useTranslation()
const { mode } = useTheme()
const navigation = useNavigation()
const localActiveIndex = useSelector(getLocalActiveIndex)
const [segment, setSegment] = useState(0)
const pages: { title: string; page: App.Pages }[] = [
{ title: t('public:heading.segments.left'), page: 'LocalPublic' },
{ title: t('public:heading.segments.right'), page: 'Local' }
]
const screenOptions = useMemo(
() => ({
headerHideShadow: true,
headerTopInsetEnabled: false
}),
[]
)
const screenOptionsRoot = useMemo(
() => ({
headerCenter: () => (
<SegmentedControl
appearance={mode}
values={pages.map(p => p.title)}
selectedIndex={segment}
onChange={({ nativeEvent }) =>
setSegment(nativeEvent.selectedSegmentIndex)
}
style={styles.segmentsContainer}
/>
),
headerRight: () => (
<HeaderRight
content='Search'
onPress={() => {
analytics('search_tap', { page: pages[segment].page })
navigation.navigate('Tab-Public', { screen: 'Tab-Shared-Search' })
}}
/>
)
}),
[mode, segment, i18n.language]
)
const routes = pages.map(p => ({ key: p.page }))
const renderPager = useCallback(
props => <ViewPagerAdapter {...props} />,
[]
)
const renderScene = useCallback(
({
route
}: {
route: {
key: App.Pages
}
}) => {
return localActiveIndex !== null && <Timeline page={route.key} />
},
[localActiveIndex]
)
const children = useCallback(
() => (
<TabView
lazy
swipeEnabled
renderPager={renderPager}
renderScene={renderScene}
renderTabBar={() => null}
onIndexChange={index => setSegment(index)}
navigationState={{ index: segment, routes }}
initialLayout={{ width: Dimensions.get('screen').width }}
/>
),
[segment]
)
return (
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name='Tab-Public-Root'
options={screenOptionsRoot}
children={children}
/>
{sharedScreens(Stack as any)}
</Stack.Navigator>
)
},
() => true
)
const styles = StyleSheet.create({
segmentsContainer: {
flexBasis: '65%'
}
})
export default TabPublic

View File

@ -33,12 +33,28 @@ const instancesMigration = {
})
}
}
},
3: (state: InstancesState) => {
return {
...state,
local: {
...state.local,
instances: state.local.instances.map(instance => {
if (!instance.urls) {
instance.urls = {
streaming_api: `wss://${instance.url}`
}
}
return instance
})
}
}
}
}
const instancesPersistConfig = {
key: 'instances',
prefix,
version: 2,
version: 3,
storage: secureStorage,
migrate: createMigrate(instancesMigration, { debug: true })
}

View File

@ -80,14 +80,6 @@ const queryFunction = ({
params
})
case 'RemotePublic':
return client<Mastodon.Status[]>({
method: 'get',
instance: 'remote',
url: 'timelines/public',
params
})
case 'Notifications':
return client<Mastodon.Notification[]>({
method: 'get',

View File

@ -15,6 +15,7 @@ export type InstanceLocal = {
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
max_toot_chars: number
account: {
id: Mastodon.Account['id']
@ -23,6 +24,7 @@ export type InstanceLocal = {
preferences: Mastodon.Preferences
}
notification: {
readTime?: Mastodon.Notification['created_at']
latestTime?: Mastodon.Notification['created_at']
}
drafts: ComposeStateDraft[]
@ -57,13 +59,13 @@ export const localAddInstance = createAsyncThunk(
async ({
url,
token,
uri,
instance,
max_toot_chars = 500,
appData
}: {
url: InstanceLocal['url']
token: InstanceLocal['token']
uri: Mastodon.Instance['uri']
instance: Mastodon.Instance
max_toot_chars?: number
appData: InstanceLocal['appData']
}): Promise<{ type: 'add' | 'overwrite'; data: InstanceLocal }> => {
@ -112,7 +114,8 @@ export const localAddInstance = createAsyncThunk(
appData,
url,
token,
uri,
uri: instance.uri,
urls: instance.urls,
max_toot_chars,
account: {
id,
@ -121,6 +124,7 @@ export const localAddInstance = createAsyncThunk(
preferences
},
notification: {
readTime: undefined,
latestTime: undefined
},
drafts: []
@ -209,8 +213,10 @@ const instancesSlice = createSlice({
action: PayloadAction<Partial<InstanceLocal['notification']>>
) => {
if (state.local.activeIndex !== null) {
state.local.instances[state.local.activeIndex].notification =
action.payload
state.local.instances[state.local.activeIndex].notification = {
...state.local.instances[state.local.activeIndex].notification,
...action.payload
}
}
},
updateLocalDraft: (state, action: PayloadAction<ComposeStateDraft>) => {
@ -297,6 +303,8 @@ export const getLocalActiveIndex = ({ instances: { local } }: RootState) =>
local.activeIndex
export const getLocalInstances = ({ instances: { local } }: RootState) =>
local.instances
export const getLocalInstance = ({ instances: { local } }: RootState) =>
local.activeIndex !== null ? local.instances[local.activeIndex] : undefined
export const getLocalUrl = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].url
@ -305,6 +313,10 @@ export const getLocalUri = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].uri
: undefined
export const getLocalUrls = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].urls
: undefined
export const getLocalMaxTootChar = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].max_toot_chars

View File

@ -8857,6 +8857,11 @@ realpath-native@^2.0.0:
resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866"
integrity sha512-v1SEYUOXXdbBZK8ZuNgO4TBjamPsiSgcFr0aP+tEKpQZK8vooEUqV6nm6Cv502mX4NF2EfsnVqtNAHG+/6Ur1Q==
reconnecting-websocket@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
redent@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa"