Basic notification working

This commit is contained in:
Zhiyuan Zheng 2021-02-28 17:41:21 +01:00
parent 4eea2bf58c
commit 78898059cb
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
12 changed files with 246 additions and 137 deletions

View File

@ -118,6 +118,7 @@
"react-navigation": "^4.4.3", "react-navigation": "^4.4.3",
"react-navigation-stack": "^2.10.2", "react-navigation-stack": "^2.10.2",
"react-test-renderer": "^16.13.1", "react-test-renderer": "^16.13.1",
"typescript": "~4.1.3" "typescript": "~4.1.3",
"uri-scheme": "^1.0.67"
} }
} }

View File

@ -71,7 +71,7 @@ declare namespace Nav {
'Tab-Local': undefined 'Tab-Local': undefined
'Tab-Public': undefined 'Tab-Public': undefined
'Tab-Compose': undefined 'Tab-Compose': undefined
'Tab-Notifications': { id?: Mastodon.Notification['id'] } 'Tab-Notifications': undefined
'Tab-Me': undefined 'Tab-Me': undefined
} }

View File

@ -8,18 +8,17 @@ import ScreenAnnouncements from '@screens/Announcements'
import ScreenCompose from '@screens/Compose' import ScreenCompose from '@screens/Compose'
import ScreenImagesViewer from '@screens/ImagesViewer' import ScreenImagesViewer from '@screens/ImagesViewer'
import ScreenTabs from '@screens/Tabs' import ScreenTabs from '@screens/Tabs'
import { useAnnouncementQuery } from '@utils/queryHooks/announcement'
import { updatePreviousTab } from '@utils/slices/contextsSlice' import { updatePreviousTab } from '@utils/slices/contextsSlice'
import { connectInstancesPush } from '@utils/slices/instances/connectPush'
import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences' import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences'
import { getInstanceActive } from '@utils/slices/instancesSlice' import { getInstanceActive } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes' import { themes } from '@utils/styles/themes'
import * as Analytics from 'expo-firebase-analytics' import * as Analytics from 'expo-firebase-analytics'
import * as Notifications from 'expo-notifications'
import { addScreenshotListener } from 'expo-screen-capture' import { addScreenshotListener } from 'expo-screen-capture'
import React, { createRef, useCallback, useEffect, useRef } from 'react' import React, { createRef, useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Linking, Platform, StatusBar } from 'react-native' import { Alert, Platform, StatusBar } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Toast from 'react-native-toast-message' import Toast from 'react-native-toast-message'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
@ -27,35 +26,6 @@ import * as Sentry from 'sentry-expo'
const Stack = createNativeStackNavigator<Nav.RootStackParamList>() const Stack = createNativeStackNavigator<Nav.RootStackParamList>()
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 { export interface Props {
localCorrupt?: string localCorrupt?: string
} }
@ -87,6 +57,11 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
// } // }
// }, [isConnected, firstRender]) // }, [isConnected, firstRender])
// Update Expo Token to server
useEffect(() => {
dispatch(connectInstancesPush())
}, [])
// Prevent screenshot alert // Prevent screenshot alert
useEffect(() => { useEffect(() => {
const screenshotListener = addScreenshotListener(() => const screenshotListener = addScreenshotListener(() =>
@ -116,23 +91,6 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
return showLocalCorrect() return showLocalCorrect()
}, [localCorrupt]) }, [localCorrupt])
// On launch check if there is any unread announcements
useAnnouncementQuery({
showAll: false,
options: {
notifyOnChangeProps: [],
select: announcements =>
announcements.filter(announcement => !announcement.read),
onSuccess: data => {
if (data.length) {
navigationRef.current?.navigate('Screen-Announcements', {
showAll: false
})
}
}
}
})
// Lazily update users's preferences, for e.g. composing default visibility // Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => { useEffect(() => {
if (instanceActive !== -1) { if (instanceActive !== -1) {
@ -175,7 +133,6 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
theme={themes[mode]} theme={themes[mode]}
onReady={navigationContainerOnReady} onReady={navigationContainerOnReady}
onStateChange={navigationContainerOnStateChange} onStateChange={navigationContainerOnStateChange}
linking={linking}
> >
<Stack.Navigator initialRouteName='Screen-Tabs'> <Stack.Navigator initialRouteName='Screen-Tabs'>
<Stack.Screen <Stack.Screen

View File

@ -6,7 +6,10 @@ import * as WebBrowser from 'expo-web-browser'
const openLink = async (url: string) => { const openLink = async (url: string) => {
switch (getSettingsBrowser(store.getState())) { switch (getSettingsBrowser(store.getState())) {
case 'internal': case 'internal':
await WebBrowser.openBrowserAsync(url) await WebBrowser.openBrowserAsync(url, {
dismissButtonStyle: 'close',
enableBarCollapsing: true
})
break break
case 'external': case 'external':
await Linking.openURL(url) await Linking.openURL(url)

View File

@ -1,3 +1,4 @@
import apiInstance from '@api/instance'
import useWebsocket from '@api/websocket' import useWebsocket from '@api/websocket'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
@ -13,10 +14,14 @@ import {
getInstanceAccount, getInstanceAccount,
getInstanceActive, getInstanceActive,
getInstanceNotification, getInstanceNotification,
getInstances,
updateInstanceActive,
updateInstanceNotification updateInstanceNotification
} from '@utils/slices/instancesSlice' } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react' import * as Notifications from 'expo-notifications'
import { findIndex } from 'lodash'
import React, { useCallback, useEffect, useMemo } from 'react'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
@ -38,10 +43,97 @@ export type ScreenTabsProp = StackScreenProps<
'Screen-Tabs' 'Screen-Tabs'
> >
const convertNotificationToToot = (
navigation: any,
id: Mastodon.Notification['id']
) => {
apiInstance<Mastodon.Notification>({
method: 'get',
url: `notifications/${id}`
}).then(({ body }) => {
// @ts-ignore
navigation.navigate('Tab-Notifications', {
screen: 'Tab-Notifications-Root'
})
if (body.status) {
// @ts-ignore
navigation.navigate('Tab-Notifications', {
screen: 'Tab-Shared-Toot',
params: { toot: body.status }
})
}
})
}
const Tab = createBottomTabNavigator<Nav.ScreenTabsStackParamList>() const Tab = createBottomTabNavigator<Nav.ScreenTabsStackParamList>()
const ScreenTabs = React.memo( const ScreenTabs = React.memo(
({ navigation }: ScreenTabsProp) => { ({ navigation }: ScreenTabsProp) => {
// Push notifications
const instances = useSelector(
getInstances,
(prev, next) => prev.length === next.length
)
const lastNotificationResponse = Notifications.useLastNotificationResponse()
useEffect(() => {
const subscription = Notifications.addNotificationResponseReceivedListener(
({ notification }) => {
const payloadData = notification.request.content.data as {
notification_id?: string
instanceUrl: string
accountId: string
}
const notificationIndex = findIndex(
instances,
instance =>
instance.url === payloadData.instanceUrl &&
instance.account.id === payloadData.accountId
)
if (notificationIndex !== -1) {
dispatch(updateInstanceActive(instances[notificationIndex]))
}
if (payloadData?.notification_id) {
convertNotificationToToot(
navigation,
notification.request.content.data.notification_id as string
)
}
}
)
return () => subscription.remove()
// if (
// lastNotificationResponse &&
// lastNotificationResponse.actionIdentifier ===
// Notifications.DEFAULT_ACTION_IDENTIFIER
// ) {
// const payloadData = lastNotificationResponse.notification.request
// .content.data as {
// notification_id?: string
// instanceUrl: string
// accountId: string
// }
// const notificationIndex = findIndex(
// instances,
// instance =>
// instance.url === payloadData.instanceUrl &&
// instance.account.id === payloadData.accountId
// )
// if (notificationIndex !== -1) {
// dispatch(updateInstanceActive(instances[notificationIndex]))
// }
// if (payloadData?.notification_id) {
// convertNotificationToToot(
// navigation,
// lastNotificationResponse.notification.request.content.data
// .notification_id as string
// )
// }
// }
}, [instances, lastNotificationResponse])
const { mode, theme } = useTheme() const { mode, theme } = useTheme()
const dispatch = useDispatch() const dispatch = useDispatch()
const instanceActive = useSelector(getInstanceActive) const instanceActive = useSelector(getInstanceActive)

View File

@ -1,12 +1,16 @@
import Timeline from '@components/Timeline' import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React from 'react' import React, { useCallback } from 'react'
const ScreenMeBookmarks = React.memo( const ScreenMeBookmarks = React.memo(
() => { () => {
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }] const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Bookmarks' }]
const renderItem = useCallback(
return <Timeline queryKey={queryKey} /> ({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
}, },
() => true () => true
) )

View File

@ -1,12 +1,17 @@
import Timeline from '@components/Timeline' import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React from 'react' import React, { useCallback } from 'react'
const ScreenMeFavourites = React.memo( const ScreenMeFavourites = React.memo(
() => { () => {
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Favourites' }] const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Favourites' }]
const renderItem = useCallback(
({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
return <Timeline queryKey={queryKey} /> return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
}, },
() => true () => true
) )

View File

@ -1,18 +1,17 @@
import { MenuRow } from '@components/Menu' import { MenuRow } from '@components/Menu'
import TimelineEmpty from '@components/Timeline/Empty'
import { StackScreenProps } from '@react-navigation/stack' import { StackScreenProps } from '@react-navigation/stack'
import { useListsQuery } from '@utils/queryHooks/lists' import { useListsQuery } from '@utils/queryHooks/lists'
import React, { useMemo } from 'react' import React from 'react'
const ScreenMeLists: React.FC<StackScreenProps< const ScreenMeLists: React.FC<StackScreenProps<
Nav.TabMeStackParamList, Nav.TabMeStackParamList,
'Tab-Me-Switch' 'Tab-Me-Lists'
>> = ({ navigation }) => { >> = ({ navigation }) => {
const { status, data, refetch } = useListsQuery({}) const { data } = useListsQuery({})
const children = useMemo(() => { return (
if (status === 'success') { <>
return data?.map((d: Mastodon.List, i: number) => ( {data?.map((d: Mastodon.List, i: number) => (
<MenuRow <MenuRow
key={i} key={i}
iconFront='List' iconFront='List'
@ -24,13 +23,9 @@ const ScreenMeLists: React.FC<StackScreenProps<
}) })
} }
/> />
)) ))}
} else { </>
return <TimelineEmpty status={status} refetch={refetch} /> )
}
}, [status])
return <>{children}</>
} }
export default ScreenMeLists export default ScreenMeLists

View File

@ -1,6 +1,7 @@
import { MenuContainer, MenuRow } from '@components/Menu' import { MenuContainer, MenuRow } from '@components/Menu'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { useAnnouncementQuery } from '@utils/queryHooks/announcement' import { useAnnouncementQuery } from '@utils/queryHooks/announcement'
import { useListsQuery } from '@utils/queryHooks/lists'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -8,26 +9,54 @@ const Collections: React.FC = () => {
const { t, i18n } = useTranslation('meRoot') const { t, i18n } = useTranslation('meRoot')
const navigation = useNavigation() const navigation = useNavigation()
const { data, isFetching } = useAnnouncementQuery({ const listsQuery = useListsQuery({
showAll: true options: {
}) notifyOnChangeProps: []
const announcementContent = useMemo(() => {
if (data) {
if (data.length === 0) {
return t('content.collections.announcements.content.empty')
} else {
const amount = data.filter(announcement => !announcement.read).length
if (amount) {
return t('content.collections.announcements.content.unread', {
amount
})
} else {
return t('content.collections.announcements.content.read')
}
}
} }
}, [data, i18n.language]) })
const rowLists = useMemo(() => {
if (listsQuery.isSuccess && listsQuery.data?.length) {
return (
<MenuRow
iconFront='List'
iconBack='ChevronRight'
title={t('content.collections.lists')}
onPress={() => navigation.navigate('Tab-Me-Lists')}
/>
)
}
}, [listsQuery.isSuccess, listsQuery.data, i18n.language])
const announcementsQuery = useAnnouncementQuery({
showAll: true,
options: {
notifyOnChangeProps: []
}
})
const rowAnnouncements = useMemo(() => {
if (announcementsQuery.isSuccess && announcementsQuery.data?.length) {
const amount = announcementsQuery.data.filter(
announcement => !announcement.read
).length
return (
<MenuRow
iconFront='Clipboard'
iconBack='ChevronRight'
title={t('content.collections.announcements.heading')}
content={
amount
? t('content.collections.announcements.content.unread', {
amount
})
: t('content.collections.announcements.content.read')
}
onPress={() =>
navigation.navigate('Screen-Announcements', { showAll: true })
}
/>
)
}
}, [announcementsQuery.isSuccess, announcementsQuery.data, i18n.language])
return ( return (
<MenuContainer> <MenuContainer>
@ -49,24 +78,8 @@ const Collections: React.FC = () => {
title={t('content.collections.favourites')} title={t('content.collections.favourites')}
onPress={() => navigation.navigate('Tab-Me-Favourites')} onPress={() => navigation.navigate('Tab-Me-Favourites')}
/> />
<MenuRow {rowLists}
iconFront='List' {rowAnnouncements}
iconBack='ChevronRight'
title={t('content.collections.lists')}
onPress={() => navigation.navigate('Tab-Me-Lists')}
/>
<MenuRow
iconFront='Clipboard'
iconBack={data && data.length === 0 ? undefined : 'ChevronRight'}
title={t('content.collections.announcements.heading')}
content={announcementContent}
loading={isFetching}
onPress={() =>
data &&
data.length &&
navigation.navigate('Screen-Announcements', { showAll: true })
}
/>
</MenuContainer> </MenuContainer>
) )
} }

View File

@ -43,32 +43,32 @@ const TabNotifications = React.memo(
<Timeline <Timeline
queryKey={queryKey} queryKey={queryKey}
customProps={{ customProps={{
renderItem, renderItem
viewabilityConfigCallbackPairs: [ // viewabilityConfigCallbackPairs: [
{ // {
onViewableItemsChanged: ({ // onViewableItemsChanged: ({
viewableItems // viewableItems
}: { // }: {
viewableItems: ViewToken[] // viewableItems: ViewToken[]
}) => { // }) => {
if ( // if (
navigation.isFocused() && // navigation.isFocused() &&
viewableItems.length && // viewableItems.length &&
viewableItems[0].index === 0 // viewableItems[0].index === 0
) { // ) {
dispatch( // dispatch(
updateInstanceNotification({ // updateInstanceNotification({
readTime: viewableItems[0].item.created_at // readTime: viewableItems[0].item.created_at
}) // })
) // )
} // }
}, // },
viewabilityConfig: { // viewabilityConfig: {
minimumViewTime: 100, // minimumViewTime: 100,
itemVisiblePercentThreshold: 60 // itemVisiblePercentThreshold: 60
} // }
} // }
] // ]
}} }}
/> />
), ),

View File

@ -0,0 +1,34 @@
import apiGeneral from '@api/general'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import * as Notifications from 'expo-notifications'
import { PUSH_SERVER } from '../instancesSlice'
export const connectInstancesPush = createAsyncThunk(
'instances/connectPush',
async (_, { getState }): Promise<any> => {
const state = getState() as RootState
const pushEnabled = state.instances.instances.filter(
instance => instance.push.global.value
)
if (pushEnabled.length) {
const expoToken = (
await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot'
})
).data
return apiGeneral({
method: 'post',
domain: PUSH_SERVER,
url: 'v1/connect',
body: {
expoToken
}
})
} else {
return Promise.resolve()
}
}
)

View File

@ -10286,6 +10286,11 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" punycode "^2.1.0"
uri-scheme@^1.0.67:
version "1.0.67"
resolved "https://registry.yarnpkg.com/uri-scheme/-/uri-scheme-1.0.67.tgz#a18c8752489967783eb94784b9d08adaf124d9eb"
integrity sha512-q0xH1d4w3fMaEJfpmA+mXN+L9fzV8bBZz96llWLG4TXO98vbMeXptDhvasQP30y/okwsQnO5gbVYJmeZW2OWIw==
urix@^0.1.0: urix@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"