Rewrite timeline logic

This commit is contained in:
Zhiyuan Zheng 2021-02-27 16:33:54 +01:00
parent 45681fc1f5
commit f3fa6bc662
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
67 changed files with 1980 additions and 1395 deletions

View File

@ -2,7 +2,7 @@ name: Publish production
on:
push:
branches:
- production
- main
jobs:
publish:
runs-on: ubuntu-latest

View File

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

View File

@ -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 <me@xmflsct.com>",
"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"
}
}
}

View File

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

View File

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

View File

@ -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<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 {
localCorrupt?: string
}
@ -57,15 +88,15 @@ const Screens: 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.select({ ios: screenshotListener })
return () => screenshotListener.remove()
}, [])
// On launch display login credentials corrupt information
useEffect(() => {
@ -127,6 +158,10 @@ const Screens: React.FC<Props> = ({ 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<Props> = ({ localCorrupt }) => {
theme={themes[mode]}
onReady={navigationContainerOnReady}
onStateChange={navigationContainerOnStateChange}
linking={linking}
>
<Stack.Navigator initialRouteName='Screen-Tabs'>
<Stack.Screen
@ -185,9 +221,9 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
/>
</Stack.Navigator>
{Platform.OS === 'ios' ? (
<Toast ref={Toast.setRef} config={toastConfig} />
) : null}
{Platform.select({
ios: <Toast ref={Toast.setRef} config={toastConfig} />
})}
</NavigationContainer>
</>
)

View File

@ -57,13 +57,9 @@ const MenuRow: React.FC<Props> = ({
return (
<View style={styles.base}>
<TapGestureHandler
onHandlerStateChange={({ nativeEvent }) => {
if (nativeEvent.state === State.ACTIVE) {
if (!loading) {
onPress && onPress()
}
}
}}
onHandlerStateChange={({ nativeEvent }) =>
nativeEvent.state === State.ACTIVE && !loading && onPress && onPress()
}
>
<View style={styles.core}>
<View style={styles.front}>
@ -82,11 +78,6 @@ const MenuRow: React.FC<Props> = ({
>
{title}
</Text>
{description ? (
<Text style={[styles.description, { color: theme.secondary }]}>
{description}
</Text>
) : null}
</View>
</View>
@ -94,21 +85,18 @@ const MenuRow: React.FC<Props> = ({
<View style={styles.back}>
{content ? (
typeof content === 'string' ? (
<>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
{loading && !iconBack && loadingSpinkit}
</>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
) : (
content
)
@ -119,23 +107,27 @@ const MenuRow: React.FC<Props> = ({
onValueChange={switchOnValueChange}
disabled={switchDisabled}
trackColor={{ true: theme.blue, false: theme.disabled }}
style={{ opacity: loading ? 0 : 1 }}
/>
) : null}
{iconBack ? (
<>
<Icon
name={iconBack}
size={StyleConstants.Font.Size.L}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/>
{loading && loadingSpinkit}
</>
<Icon
name={iconBack}
size={StyleConstants.Font.Size.L}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/>
) : null}
{loading && loadingSpinkit}
</View>
) : null}
</View>
</TapGestureHandler>
{description ? (
<Text style={[styles.description, { color: theme.secondary }]}>
{description}
</Text>
) : null}
</View>
)
}
@ -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

View File

@ -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<FlatList<any>>
queryKey: QueryKeyTimeline
disableRefresh?: boolean
disableInfinity?: boolean
customProps?: Partial<FlatListProps<any>>
customProps: Partial<FlatListProps<any>> &
Pick<FlatListProps<any>, 'renderItem'>
}
const Timeline: React.FC<Props> = ({
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<Props> = ({
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<FlatList<any>>(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 (
<TimelineConversation conversation={item} queryKey={queryKey} />
)
case 'Notifications':
return (
<TimelineNotifications notification={item} queryKey={queryKey} />
)
default:
return (
<TimelineDefault
item={item}
queryKey={queryKey}
{...(toot === item.id && { highlighted: true })}
{...(toot && { rootQueryKey })}
// @ts-ignore
{...(data?.pages[0].pinned && { pinned: data?.pages[0].pinned })}
/>
)
}
},
[data?.pages[0]]
)
const ItemSeparatorComponent = useCallback(
({ leadingItem }) => (
<ComponentSeparator
{...(toot === leadingItem.id
? { extraMarginLeft: 0 }
: {
extraMarginLeft:
StyleConstants.Avatar.M + StyleConstants.Spacing.S
})}
/>
),
({ leadingItem }) =>
queryKey[1].page === 'Toot' && queryKey[1].toot === leadingItem.id ? (
<ComponentSeparator extraMarginLeft={0} />
) : (
<ComponentSeparator
extraMarginLeft={StyleConstants.Avatar.M + StyleConstants.Spacing.S}
/>
),
[]
)
const flItemEmptyComponent = useMemo(
() => <TimelineEmpty status={status} refetch={refetch} />,
[status]
)
const onEndReached = useCallback(
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const ListFooterComponent = useMemo(
() => <TimelineEnd hasNextPage={!disableInfinity ? hasNextPage : false} />,
[hasNextPage]
)
useScrollToTop(flRef)
const queryClient = useQueryClient()
const flRef = useRef<FlatList>(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<InfiniteData<any> | 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<InfiniteData<any> | 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<InfiniteData<any> | 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(
() => <Animated.View style={headerPadding} />,
[]
},
[isFetching]
)
const androidRefreshControl = useMemo(
() =>
Platform.OS === 'android' && {
refreshControl: (
<RefreshControl
enabled
colors={[theme.primary]}
progressBackgroundColor={theme.background}
refreshing={isFetching || isLoading}
onRefresh={() => refetch()}
/>
)
},
[isFetching, isLoading]
)
const androidRefreshControl = Platform.select({
android: {
refreshControl: (
<RefreshControl
enabled
colors={[theme.primary]}
progressBackgroundColor={theme.background}
refreshing={isFetching || isLoading}
onRefresh={() => refetch()}
/>
)
}
})
useScrollToTop(flRef)
return (
<>
<TimelineRefresh
queryKey={queryKey}
scrollY={scrollY}
isLoading={isLoading}
isFetching={isFetching}
disable={disableRefresh}
fetchingType={fetchingType}
disableRefresh={disableRefresh}
/>
<FlatList
<AnimatedFlatList
// @ts-ignore
ref={customFLRef || flRef}
scrollEventThrottle={16}
onScroll={onScroll}
onResponderRelease={onResponderRelease}
ref={flRef}
windowSize={8}
data={flattenData}
initialNumToRender={6}
maxToRenderPerBatch={3}
style={styles.flatList}
renderItem={renderItem}
onEndReached={onEndReached}
keyExtractor={keyExtractor}
onEndReachedThreshold={0.75}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={flItemEmptyComponent}
ListFooterComponent={
<TimelineFooter
queryKey={queryKey}
disableInfinity={disableInfinity}
/>
}
ListEmptyComponent={<TimelineEmpty queryKey={queryKey} />}
ItemSeparatorComponent={ItemSeparatorComponent}
{...(toot && isSuccess && { onScrollToIndexFailed })}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}

View File

@ -41,10 +41,7 @@ const TimelineDefault: React.FC<Props> = ({
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<Nav.TabLocalStackParamList>
>()

View File

@ -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<Props> = ({ 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 (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
)
case 'error':
return (
<>
<Icon
name='Frown'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text>
<Button
type='text'
content={t('empty.error.button')}
onPress={() => {
analytics('timeline_error_press_refetch')
refetch()
}}
/>
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text>
</>
)
}
}, [mode, i18n.language, status])
return (
<View
style={[styles.base, { backgroundColor: theme.background }]}
children={children}
/>
)
}
const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('componentTimeline')
const children = useMemo(() => {
switch (status) {
case 'loading':
return (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
)
case 'error':
return (
<>
<Icon
name='Frown'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text>
<Button
type='text'
content={t('empty.error.button')}
onPress={() => {
analytics('timeline_error_press_refetch')
refetch()
}}
/>
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text>
</>
)
}
}, [mode, i18n.language, status])
return (
<View
style={[styles.base, { backgroundColor: theme.background }]}
children={children}
/>
)
},
() => true
)
const styles = StyleSheet.create({
base: {

View File

@ -1,50 +0,0 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Trans } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
export interface Props {
hasNextPage?: boolean
}
const TimelineEnd: React.FC<Props> = ({ hasNextPage }) => {
const { theme } = useTheme()
return (
<View style={styles.base}>
{hasNextPage ? (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : (
<Text style={[styles.text, { color: theme.secondary }]}>
<Trans
i18nKey='componentTimeline:end.message'
components={[
<Icon
name='Coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
base: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
padding: StyleConstants.Spacing.M
},
text: {
...StyleConstants.FontStyle.S
}
})
export default TimelineEnd

View File

@ -0,0 +1,63 @@
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 from 'react'
import { Trans } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
export interface Props {
queryKey: QueryKeyTimeline
disableInfinity: boolean
}
const TimelineFooter = React.memo(
({ queryKey, disableInfinity }: Props) => {
const { hasNextPage } = useTimelineQuery({
...queryKey[1],
options: {
enabled: !disableInfinity,
notifyOnChangeProps: ['hasNextPage']
}
})
const { theme } = useTheme()
return (
<View style={styles.base}>
{!disableInfinity && hasNextPage ? (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : (
<Text style={[styles.text, { color: theme.secondary }]}>
<Trans
i18nKey='componentTimeline:end.message'
components={[
<Icon
name='Coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text>
)}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
padding: StyleConstants.Spacing.M
},
text: {
...StyleConstants.FontStyle.S
}
})
export default TimelineFooter

View File

@ -1,8 +1,13 @@
import haptics from '@components/haptics'
import Icon from '@components/Icon'
import {
QueryKeyTimeline,
TimelineData,
useTimelineQuery
} from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
@ -15,104 +20,201 @@ import Animated, {
useSharedValue,
withTiming
} from 'react-native-reanimated'
import { InfiniteData, useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKeyTimeline
scrollY: Animated.SharedValue<number>
isLoading: boolean
isFetching: boolean
disable?: boolean
fetchingType: Animated.SharedValue<0 | 1 | 2>
disableRefresh?: boolean
}
const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5
export const SEPARATION_Y_1 = -(
CONTAINER_HEIGHT / 2 +
StyleConstants.Font.Size.S / 2
)
export const SEPARATION_Y_2 = -(
CONTAINER_HEIGHT * 1.5 +
StyleConstants.Font.Size.S / 2
)
const TimelineRefresh = React.memo(
({ scrollY, isLoading, isFetching, disable = false }: Props) => {
if (disable || isLoading) {
return null
const TimelineRefresh: React.FC<Props> = ({
queryKey,
scrollY,
fetchingType,
disableRefresh = false
}) => {
if (disableRefresh) {
return null
}
const fetchingLatestIndex = useRef(0)
const {
data,
refetch,
isFetching,
isLoading,
fetchPreviousPage,
hasPreviousPage,
isFetchingNextPage
} = useTimelineQuery({
...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'
},
onSuccess: () => {
if (fetchingLatestIndex.current > 0) {
if (fetchingLatestIndex.current > 8) {
clearFirstPage()
fetchingLatestIndex.current = 0
} else {
if (hasPreviousPage) {
fetchPreviousPage()
fetchingLatestIndex.current++
} else {
clearFirstPage()
fetchingLatestIndex.current = 0
}
}
}
}
}
})
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const separation01 = -(
CONTAINER_HEIGHT / 2 +
StyleConstants.Font.Size.S / 2
)
const separation02 = -(
CONTAINER_HEIGHT * 1.5 +
StyleConstants.Font.Size.S / 2
)
const [textRight, setTextRight] = useState(0)
const arrowY = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(
scrollY.value,
[0, separation01],
[
-CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2,
CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2
],
Extrapolate.CLAMP
)
const queryClient = useQueryClient()
const clearFirstPage = useCallback(() => {
if (data?.pages[0].body.length === 0) {
queryClient.setQueryData<InfiniteData<TimelineData> | 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 arrowTop = useAnimatedStyle(() => ({
marginTop:
scrollY.value < separation02
? withTiming(CONTAINER_HEIGHT)
: withTiming(0)
}))
)
}
}, [data?.pages.length && data?.pages[0].body.length])
const arrowStage = useSharedValue(0)
const onLayout = useCallback(
({ nativeEvent }) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
},
[textRight]
)
useAnimatedReaction(
() => {
if (isFetching) {
const [textRight, setTextRight] = useState(0)
const arrowY = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(
scrollY.value,
[0, SEPARATION_Y_1],
[
-CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2,
CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2
],
Extrapolate.CLAMP
)
}
]
}))
const arrowTop = useAnimatedStyle(() => ({
marginTop:
scrollY.value < SEPARATION_Y_2
? withTiming(CONTAINER_HEIGHT)
: withTiming(0)
}))
const arrowStage = useSharedValue(0)
const onLayout = useCallback(
({ nativeEvent }) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
},
[textRight]
)
useAnimatedReaction(
() => {
if (isFetching) {
return false
}
switch (arrowStage.value) {
case 0:
if (scrollY.value < SEPARATION_Y_1) {
arrowStage.value = 1
return true
}
return false
}
switch (arrowStage.value) {
case 0:
if (scrollY.value < separation01) {
arrowStage.value = 1
return true
}
case 1:
if (scrollY.value < SEPARATION_Y_2) {
arrowStage.value = 2
return true
}
if (scrollY.value > SEPARATION_Y_1) {
arrowStage.value = 0
return false
case 1:
if (scrollY.value < separation02) {
arrowStage.value = 2
return true
}
if (scrollY.value > separation01) {
arrowStage.value = 0
return false
}
}
return false
case 2:
if (scrollY.value > SEPARATION_Y_2) {
arrowStage.value = 1
return false
case 2:
if (scrollY.value > separation02) {
arrowStage.value = 1
return false
}
return false
}
},
data => {
if (data) {
runOnJS(haptics)('Light')
}
},
[isFetching]
)
}
return false
}
},
data => {
if (data) {
runOnJS(haptics)('Light')
}
},
[isFetching]
)
const wrapper = () => {
fetchingLatestIndex.current = 1
}
useAnimatedReaction(
() => {
return fetchingType.value
},
data => {
fetchingType.value = 0
switch (data) {
case 1:
runOnJS(wrapper)()
runOnJS(clearFirstPage)()
runOnJS(fetchPreviousPage)()
break
case 2:
runOnJS(clearFirstPage)()
runOnJS(refetch)()
break
}
},
[]
)
return (
const headerPadding = useAnimatedStyle(
() => ({
paddingTop:
fetchingLatestIndex.current !== 0 ||
(isFetching && !isLoading && !isFetchingNextPage)
? withTiming(StyleConstants.Spacing.M * 2.5)
: withTiming(0)
}),
[fetchingLatestIndex.current, isFetching, isFetchingNextPage, isLoading]
)
return (
<Animated.View style={headerPadding}>
<View style={styles.base}>
{isFetching ? (
<View style={styles.container2}>
@ -154,11 +256,9 @@ const TimelineRefresh = React.memo(
</>
)}
</View>
)
},
(prev, next) =>
prev.isLoading === next.isLoading && prev.isFetching === next.isFetching
)
</Animated.View>
)
}
const styles = StyleSheet.create({
base: {

View File

@ -15,140 +15,139 @@ export interface Props {
notification?: boolean
}
const TimelineActioned: React.FC<Props> = ({
account,
action,
notification = false
}) => {
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
const name = account.display_name || account.username
const iconColor = theme.primary
const TimelineActioned = React.memo(
({ account, action, notification = false }: Props) => {
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
const name = account.display_name || account.username
const iconColor = theme.primary
const content = (content: string) => (
<ParseEmojis content={content} emojis={account.emojis} size='S' />
)
const content = (content: string) => (
<ParseEmojis content={content} emojis={account.emojis} size='S' />
)
const onPress = useCallback(() => {
analytics('timeline_shared_actioned_press', { action })
navigation.push('Tab-Shared-Account', { account })
}, [])
const onPress = useCallback(() => {
analytics('timeline_shared_actioned_press', { action })
navigation.push('Tab-Shared-Account', { account })
}, [])
const children = useMemo(() => {
switch (action) {
case 'pinned':
return (
<>
<Icon
name='Anchor'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.pinned'))}
</>
)
break
case 'favourite':
return (
<>
<Icon
name='Heart'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.favourite', { name }))}
</Pressable>
</>
)
break
case 'follow':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow', { name }))}
</Pressable>
</>
)
break
case 'follow_request':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow_request', { name }))}
</Pressable>
</>
)
break
case 'poll':
return (
<>
<Icon
name='BarChart2'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.poll'))}
</>
)
break
case 'reblog':
return (
<>
<Icon
name='Repeat'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable>
</>
)
break
case 'status':
return (
<>
<Icon
name='Activity'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.status', { name }))}
</Pressable>
</>
)
break
}
}, [])
const children = useMemo(() => {
switch (action) {
case 'pinned':
return (
<>
<Icon
name='Anchor'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.pinned'))}
</>
)
break
case 'favourite':
return (
<>
<Icon
name='Heart'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.favourite', { name }))}
</Pressable>
</>
)
break
case 'follow':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow', { name }))}
</Pressable>
</>
)
break
case 'follow_request':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow_request', { name }))}
</Pressable>
</>
)
break
case 'poll':
return (
<>
<Icon
name='BarChart2'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.poll'))}
</>
)
break
case 'reblog':
return (
<>
<Icon
name='Repeat'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable>
</>
)
break
case 'status':
return (
<>
<Icon
name='Activity'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.status', { name }))}
</Pressable>
</>
)
break
}
}, [])
return <View style={styles.actioned} children={children} />
}
return <View style={styles.actioned} children={children} />
},
() => true
)
const styles = StyleSheet.create({
actioned: {
@ -163,4 +162,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineActioned, () => true)
export default TimelineActioned

View File

@ -56,7 +56,10 @@ const TimelineActions: React.FC<Props> = ({
theParams.payload.currentValue === true)
) {
queryClient.invalidateQueries(queryKey)
} else if (theParams.payload.property === 'reblogged') {
} else if (
theParams.payload.property === 'reblogged' &&
queryKey[1].page !== 'Following'
) {
// When reblogged, update cache of following page
const tempQueryKey: QueryKeyTimeline = [
'Timeline',

View File

@ -16,129 +16,132 @@ export interface Props {
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'>
}
const TimelineAttachment: React.FC<Props> = ({ status }) => {
const { t } = useTranslation('componentTimeline')
const TimelineAttachment = React.memo(
({ status }: Props) => {
const { t } = useTranslation('componentTimeline')
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_show')
layoutAnimation()
setSensitiveShown(false)
haptics('Light')
}, [])
const onPressShow = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_hide')
setSensitiveShown(true)
haptics('Light')
}, [])
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_show')
layoutAnimation()
setSensitiveShown(false)
haptics('Light')
}, [])
const onPressShow = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_hide')
setSensitiveShown(true)
haptics('Light')
}, [])
let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = []
const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-ImagesViewer', {
imageUrls,
imageIndex
})
const attachments = useMemo(
() =>
status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
imageUrls.push({
url: attachment.url,
preview_url: attachment.preview_url,
remote_url: attachment.remote_url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
imageIndex: index
})
return (
<AttachmentImage
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
image={attachment}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
case 'video':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default:
return (
<AttachmentUnsupported
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
attachment={attachment}
/>
)
}
}),
[sensitiveShown]
)
let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = []
const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-ImagesViewer', {
imageUrls,
imageIndex
})
const attachments = useMemo(
() =>
status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
imageUrls.push({
url: attachment.url,
preview_url: attachment.preview_url,
remote_url: attachment.remote_url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
imageIndex: index
})
return (
<AttachmentImage
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
image={attachment}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
case 'video':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default:
return (
<AttachmentUnsupported
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
attachment={attachment}
/>
)
}
}),
[sensitiveShown]
)
return (
<View>
<View style={styles.container} children={attachments} />
return (
<View>
<View style={styles.container} children={attachments} />
{status.sensitive &&
(sensitiveShown ? (
<Pressable style={styles.sensitiveBlur}>
{status.sensitive &&
(sensitiveShown ? (
<Pressable style={styles.sensitiveBlur}>
<Button
type='text'
content={t('shared.attachment.sensitive.button')}
overlay
onPress={onPressBlurView}
/>
</Pressable>
) : (
<Button
type='text'
content={t('shared.attachment.sensitive.button')}
type='icon'
content='EyeOff'
round
overlay
onPress={onPressBlurView}
onPress={onPressShow}
style={{
position: 'absolute',
top: StyleConstants.Spacing.S * 2,
left: StyleConstants.Spacing.S
}}
/>
</Pressable>
) : (
<Button
type='icon'
content='EyeOff'
round
overlay
onPress={onPressShow}
style={{
position: 'absolute',
top: StyleConstants.Spacing.S * 2,
left: StyleConstants.Spacing.S
}}
/>
))}
</View>
)
}
))}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
container: {
@ -166,4 +169,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineAttachment, () => true)
export default TimelineAttachment

View File

@ -13,31 +13,28 @@ export interface Props {
navigateToImagesViewer: (imageIndex: number) => void
}
const AttachmentImage: React.FC<Props> = ({
total,
index,
sensitiveShown,
image,
navigateToImagesViewer
}) => {
const onPress = useCallback(() => {
analytics('timeline_shared_attachment_image_press', { id: image.id })
navigateToImagesViewer(index)
}, [])
const AttachmentImage = React.memo(
({ total, index, sensitiveShown, image, navigateToImagesViewer }: Props) => {
const onPress = useCallback(() => {
analytics('timeline_shared_attachment_image_press', { id: image.id })
navigateToImagesViewer(index)
}, [])
return (
<GracefullyImage
hidden={sensitiveShown}
uri={{ original: image.preview_url, remote: image.remote_url }}
blurhash={image.blurhash}
onPress={onPress}
style={[
styles.base,
{ aspectRatio: attachmentAspectRatio({ total, index }) }
]}
/>
)
}
return (
<GracefullyImage
hidden={sensitiveShown}
uri={{ original: image.preview_url, remote: image.remote_url }}
blurhash={image.blurhash}
onPress={onPress}
style={[
styles.base,
{ aspectRatio: attachmentAspectRatio({ total, index }) }
]}
/>
)
},
(prev, next) => prev.sensitiveShown === next.sensitiveShown
)
const styles = StyleSheet.create({
base: {
@ -47,7 +44,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(
AttachmentImage,
(prev, next) => prev.sensitiveShown === next.sensitiveShown
)
export default AttachmentImage

View File

@ -11,33 +11,36 @@ export interface Props {
account: Mastodon.Account
}
const TimelineAvatar: React.FC<Props> = ({ queryKey, account }) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
// Need to fix go back root
const onPress = useCallback(() => {
analytics('timeline_shared_avatar_press', {
page: queryKey && queryKey[1].page
})
queryKey && navigation.push('Tab-Shared-Account', { account })
}, [])
const TimelineAvatar = React.memo(
({ queryKey, account }: Props) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
// Need to fix go back root
const onPress = useCallback(() => {
analytics('timeline_shared_avatar_press', {
page: queryKey && queryKey[1].page
})
queryKey && navigation.push('Tab-Shared-Account', { account })
}, [])
return (
<GracefullyImage
onPress={onPress}
uri={{ original: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M
}}
style={{
borderRadius: 4,
overflow: 'hidden',
marginRight: StyleConstants.Spacing.S
}}
/>
)
}
return (
<GracefullyImage
onPress={onPress}
uri={{ original: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M
}}
style={{
borderRadius: 4,
overflow: 'hidden',
marginRight: StyleConstants.Spacing.S
}}
/>
)
},
() => true
)
export default React.memo(TimelineAvatar, () => true)
export default TimelineAvatar

View File

@ -10,53 +10,56 @@ export interface Props {
card: Mastodon.Card
}
const TimelineCard: React.FC<Props> = ({ card }) => {
const { theme } = useTheme()
const TimelineCard = React.memo(
({ card }: Props) => {
const { theme } = useTheme()
return (
<Pressable
style={[styles.card, { borderColor: theme.border }]}
onPress={async () => {
analytics('timeline_shared_card_press')
await openLink(card.url)
}}
testID='base'
>
{card.image && (
<GracefullyImage
uri={{ original: card.image }}
blurhash={card.blurhash}
style={styles.left}
imageStyle={styles.image}
/>
)}
<View style={styles.right}>
<Text
numberOfLines={2}
style={[styles.rightTitle, { color: theme.primary }]}
testID='title'
>
{card.title}
</Text>
{card.description ? (
return (
<Pressable
style={[styles.card, { borderColor: theme.border }]}
onPress={async () => {
analytics('timeline_shared_card_press')
await openLink(card.url)
}}
testID='base'
>
{card.image && (
<GracefullyImage
uri={{ original: card.image }}
blurhash={card.blurhash}
style={styles.left}
imageStyle={styles.image}
/>
)}
<View style={styles.right}>
<Text
numberOfLines={2}
style={[styles.rightTitle, { color: theme.primary }]}
testID='title'
>
{card.title}
</Text>
{card.description ? (
<Text
numberOfLines={1}
style={[styles.rightDescription, { color: theme.primary }]}
testID='description'
>
{card.description}
</Text>
) : null}
<Text
numberOfLines={1}
style={[styles.rightDescription, { color: theme.primary }]}
testID='description'
style={[styles.rightLink, { color: theme.secondary }]}
>
{card.description}
{card.url}
</Text>
) : null}
<Text
numberOfLines={1}
style={[styles.rightLink, { color: theme.secondary }]}
>
{card.url}
</Text>
</View>
</Pressable>
)
}
</View>
</Pressable>
)
},
() => true
)
const styles = StyleSheet.create({
card: {
@ -93,4 +96,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineCard, () => true)
export default TimelineCard

View File

@ -11,53 +11,56 @@ export interface Props {
disableDetails?: boolean
}
const TimelineContent: React.FC<Props> = ({
status,
numberOfLines,
highlighted = false,
disableDetails = false
}) => {
const { t } = useTranslation('componentTimeline')
const TimelineContent = React.memo(
({
status,
numberOfLines,
highlighted = false,
disableDetails = false
}: Props) => {
const { t } = useTranslation('componentTimeline')
return (
<>
{status.spoiler_text ? (
<>
<View style={{ marginBottom: StyleConstants.Font.Size.M }}>
return (
<>
{status.spoiler_text ? (
<>
<View style={{ marginBottom: StyleConstants.Font.Size.M }}>
<ParseHTML
content={status.spoiler_text}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999}
disableDetails={disableDetails}
/>
</View>
<ParseHTML
content={status.spoiler_text}
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999}
numberOfLines={0}
expandHint={t('shared.content.expandHint')}
disableDetails={disableDetails}
/>
</View>
</>
) : (
<ParseHTML
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={0}
expandHint={t('shared.content.expandHint')}
numberOfLines={highlighted ? 999 : numberOfLines}
disableDetails={disableDetails}
/>
</>
) : (
<ParseHTML
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={highlighted ? 999 : numberOfLines}
disableDetails={disableDetails}
/>
)}
</>
)
}
)}
</>
)
},
() => true
)
export default React.memo(TimelineContent, () => true)
export default TimelineContent

View File

@ -44,78 +44,81 @@ export interface Props {
conversation: Mastodon.Conversation
}
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
const { t } = useTranslation('componentTimeline')
const HeaderConversation = React.memo(
({ queryKey, conversation }: Props) => {
const { t } = useTranslation('componentTimeline')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
queryClient,
onMutate: true,
onError: (err: any, _, oldData) => {
haptics('Error')
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t(`shared.header.conversation.delete.function`)
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
queryClient,
onMutate: true,
onError: (err: any, _, oldData) => {
haptics('Error')
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t(`shared.header.conversation.delete.function`)
}),
autoHide: false
})
queryClient.setQueryData(queryKey, oldData)
}
})
const { theme } = useTheme()
const actionOnPress = useCallback(() => {
analytics('timeline_conversation_delete_press')
mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
}),
autoHide: false
})
queryClient.setQueryData(queryKey, oldData)
}
})
}, [])
const actionChildren = useMemo(
() => (
<Icon
name='Trash'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
),
[]
)
const { theme } = useTheme()
return (
<View style={styles.base}>
<View style={styles.nameAndMeta}>
<Names accounts={conversation.accounts} />
<View style={styles.meta}>
{conversation.last_status?.created_at ? (
<HeaderSharedCreated
created_at={conversation.last_status?.created_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
const actionOnPress = useCallback(() => {
analytics('timeline_conversation_delete_press')
mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
})
}, [])
const actionChildren = useMemo(
() => (
<Icon
name='Trash'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
),
[]
)
return (
<View style={styles.base}>
<View style={styles.nameAndMeta}>
<Names accounts={conversation.accounts} />
<View style={styles.meta}>
{conversation.last_status?.created_at ? (
<HeaderSharedCreated
created_at={conversation.last_status?.created_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
</View>
</View>
</View>
<Pressable
style={styles.action}
onPress={actionOnPress}
children={actionChildren}
/>
</View>
)
}
<Pressable
style={styles.action}
onPress={actionOnPress}
children={actionChildren}
/>
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {

View File

@ -17,50 +17,49 @@ export interface Props {
status: Mastodon.Status
}
const TimelineHeaderDefault: React.FC<Props> = ({
queryKey,
rootQueryKey,
status
}) => {
const navigation = useNavigation()
const { theme } = useTheme()
const TimelineHeaderDefault = React.memo(
({ queryKey, rootQueryKey, status }: Props) => {
const navigation = useNavigation()
const { theme } = useTheme()
return (
<View style={styles.base}>
<View style={styles.accountAndMeta}>
<HeaderSharedAccount account={status.account} />
<View style={styles.meta}>
<HeaderSharedCreated created_at={status.created_at} />
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
return (
<View style={styles.base}>
<View style={styles.accountAndMeta}>
<HeaderSharedAccount account={status.account} />
<View style={styles.meta}>
<HeaderSharedCreated created_at={status.created_at} />
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
</View>
{queryKey ? (
<Pressable
style={styles.action}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
rootQueryKey,
status,
url: status.url || status.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
) : null}
</View>
)
}
{queryKey ? (
<Pressable
style={styles.action}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
rootQueryKey,
status,
url: status.url || status.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
) : null}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {
@ -87,7 +86,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(
TimelineHeaderDefault,
(prev, next) => prev.status.muted !== next.status.muted
)
export default TimelineHeaderDefault

View File

@ -20,98 +20,98 @@ export interface Props {
notification: Mastodon.Notification
}
const TimelineHeaderNotification: React.FC<Props> = ({
queryKey,
notification
}) => {
const navigation = useNavigation()
const { theme } = useTheme()
const TimelineHeaderNotification = React.memo(
({ queryKey, notification }: Props) => {
const navigation = useNavigation()
const { theme } = useTheme()
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
status: notification.status,
url: notification.status?.url || notification.status?.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
status: notification.status,
url: notification.status?.url || notification.status?.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={styles.base}>
<View
style={{
flex:
notification.type === 'follow' ||
notification.type === 'follow_request'
? 1
: 4
}}
>
<HeaderSharedAccount
account={
notification.status
? notification.status.account
: notification.account
}
{...((notification.type === 'follow' ||
notification.type === 'follow_request') && { withoutName: true })}
/>
<View style={styles.meta}>
<HeaderSharedCreated created_at={notification.created_at} />
{notification.status?.visibility ? (
<HeaderSharedVisibility
visibility={notification.status.visibility}
/>
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication
application={notification.status?.application}
/>
)
}
}
}, [notification.type])
</View>
</View>
return (
<View style={styles.base}>
<View
style={{
flex:
<View
style={[
styles.relationship,
notification.type === 'follow' ||
notification.type === 'follow_request'
? 1
: 4
}}
>
<HeaderSharedAccount
account={
notification.status
? notification.status.account
: notification.account
}
{...((notification.type === 'follow' ||
notification.type === 'follow_request') && { withoutName: true })}
/>
<View style={styles.meta}>
<HeaderSharedCreated created_at={notification.created_at} />
{notification.status?.visibility ? (
<HeaderSharedVisibility
visibility={notification.status.visibility}
/>
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication
application={notification.status?.application}
/>
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
<View
style={[
styles.relationship,
notification.type === 'follow' ||
notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
)
},
() => true
)
const styles = StyleSheet.create({
base: {
@ -129,4 +129,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineHeaderNotification, () => true)
export default TimelineHeaderNotification

View File

@ -14,6 +14,7 @@ export default {
meLists: require('./screens/meLists').default,
meListsList: require('./screens/meListsList').default,
meSettings: require('./screens/meSettings').default,
meSettingsPush: require('./screens/meSettingsPush').default,
meSwitch: require('./screens/meSwitch').default,
sharedAccount: require('./screens/sharedAccount').default,

View File

@ -1,6 +1,13 @@
export default {
heading: 'Settings',
content: {
push: {
heading: '$t(meSettingsPush:heading)',
content: {
enabled: 'Enabled',
disabled: 'Disabled'
}
},
language: {
heading: 'Language',
options: {

View File

@ -0,0 +1,30 @@
export default {
heading: 'Push Notification',
content: {
global: {
heading: 'Enable push notification',
description: "Messages are routed through tooot's server"
},
decode: {
heading: 'Show message details',
description:
"Messages routed through tooot's server are encrypted, but you can choose to decode the message on the server. Our server source code is open source, and no log policy."
},
follow: {
heading: 'New follower'
},
favourite: {
heading: 'Favourited'
},
reblog: {
heading: 'Boosted'
},
mention: {
heading: 'Mentioned you'
},
poll: {
heading: 'Poll updates'
},
howitworks: 'Learn how routing works'
}
}

View File

@ -14,7 +14,7 @@ export default {
meLists: require('./screens/meLists').default,
meListsList: require('./screens/meListsList').default,
meSettings: require('./screens/meSettings').default,
meSettingsNotification: require('./screens/meSettingsNotification').default,
meSettingsPush: require('./screens/meSettingsPush').default,
meSwitch: require('./screens/meSwitch').default,
sharedAccount: require('./screens/sharedAccount').default,

View File

@ -12,7 +12,7 @@ export default {
message: '居然刷到底了,喝杯 <0 /> 吧'
},
refresh: {
fetchPreviousPage: '较新于此嘟嘟',
fetchPreviousPage: '较新于此嘟嘟',
refetch: '最新的嘟嘟'
},
shared: {

View File

@ -1,8 +1,12 @@
export default {
heading: '设置',
content: {
notification: {
heading: '$t(meSettingsNotification:heading)'
push: {
heading: '$t(meSettingsPush:heading)',
content: {
enabled: '已开启',
disabled: '已关闭'
}
},
language: {
heading: '切换语言',

View File

@ -1,24 +0,0 @@
export default {
heading: '通知',
content: {
global: {
heading: '启用通知',
description: 'blahblahblah'
},
follow: {
heading: '新关注者'
},
favourite: {
heading: '嘟文被喜欢'
},
reblog: {
heading: '嘟文被转嘟'
},
mention: {
heading: '提及你'
},
poll: {
heading: '投票'
}
}
}

View File

@ -0,0 +1,29 @@
export default {
heading: '推送通知',
content: {
global: {
heading: '启用通知',
description: '通知消息将经由tooot服务器转发'
},
decode: {
heading: '显示通知内容',
description: '经由tooot服务器中转的通知消息已被加密但可以允许tooot服务器解密并转发消息。tooot消息服务器源码开源且不留存任何日志。'
},
follow: {
heading: '新关注者'
},
favourite: {
heading: '嘟文被喜欢'
},
reblog: {
heading: '嘟文被转嘟'
},
mention: {
heading: '提及你'
},
poll: {
heading: '投票'
},
howitworks: '了解通知消息转发如何工作'
}
}

View File

@ -1,7 +1,10 @@
import analytics from '@components/analytics'
import Button from '@components/Button'
import { StackScreenProps } from '@react-navigation/stack'
import { getLocalAccount, getLocalUrl } from '@utils/slices/instancesSlice'
import {
getInstanceAccount,
getInstanceUrl
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useMemo } from 'react'
@ -38,7 +41,7 @@ const ScreenActionsRoot = React.memo(
const { t } = useTranslation()
const localAccount = useSelector(
getLocalAccount,
getInstanceAccount,
(prev, next) => prev?.id === next?.id
)
let sameAccount = false
@ -51,7 +54,7 @@ const ScreenActionsRoot = React.memo(
break
}
const localDomain = useSelector(getLocalUrl)
const localDomain = useSelector(getInstanceUrl)
let sameDomain = true
let statusDomain: string
switch (params.type) {

View File

@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { forEach, groupBy, sortBy } from 'lodash'
import React, { useCallback, useContext, useEffect, useMemo } from 'react'
import { FlatList, StyleSheet, View } from 'react-native'
import { FlatList, Image, StyleSheet, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import ComposeActions from './Root/Actions'
import ComposePosting from './Posting'
@ -15,6 +15,21 @@ import ComposeRootSuggestion from './Root/Suggestion'
import ComposeContext from './utils/createContext'
import ComposeDrafts from './Root/Drafts'
const prefetchEmojis = (
sortedEmojis: { title: string; data: Mastodon.Emoji[] }[]
) => {
let requestedIndex = 0
sortedEmojis.map(sorted => {
sorted.data.map(emoji => {
if (requestedIndex > 40) {
return
}
Image.prefetch(emoji.url)
requestedIndex++
})
})
}
const ComposeRoot: React.FC = () => {
const { theme } = useTheme()
@ -52,6 +67,7 @@ const ComposeRoot: React.FC = () => {
type: 'emoji',
payload: { ...composeState.emoji, emojis: sortedEmojis }
})
prefetchEmojis(sortedEmojis)
}
}, [emojisData])

View File

@ -2,9 +2,15 @@ import analytics from '@components/analytics'
import haptics from '@components/haptics'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useContext, useMemo } from 'react'
import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native'
import FastImage from 'react-native-fast-image'
import React, { useCallback, useContext, useEffect, useMemo } from 'react'
import {
Image,
Pressable,
SectionList,
StyleSheet,
Text,
View
} from 'react-native'
import ComposeContext from '../../utils/createContext'
import updateText from '../../updateText'
@ -25,7 +31,12 @@ const SingleEmoji = ({ emoji }: { emoji: Mastodon.Emoji }) => {
haptics('Light')
}, [composeState])
const children = useMemo(
() => <FastImage source={{ uri: emoji.url }} style={styles.emoji} />,
() => (
<Image
source={{ uri: emoji.url, cache: 'force-cache' }}
style={styles.emoji}
/>
),
[]
)
return (

View File

@ -1,8 +1,10 @@
import analytics from '@components/analytics'
import { HeaderCenter, HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs'
import { ScreenTabsParamList } from '@screens/Tabs'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -48,8 +50,17 @@ const TabLocal = React.memo(
}),
[]
)
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }]
const renderItem = useCallback(
({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
const children = useCallback(
() => (instanceActive !== -1 ? <Timeline page='Following' /> : null),
() =>
instanceActive !== -1 ? (
<Timeline queryKey={queryKey} customProps={{ renderItem }} />
) : null,
[]
)

View File

@ -12,7 +12,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import ScreenMeSettingsNotification from './Me/Notification'
import ScreenMeSettingsPush from './Me/Push'
const Stack = createNativeStackNavigator<Nav.TabMeStackParamList>()
@ -116,13 +116,13 @@ const TabMe = React.memo(
})}
/>
<Stack.Screen
name='Tab-Me-Settings-Notification'
component={ScreenMeSettingsNotification}
name='Tab-Me-Settings-Push'
component={ScreenMeSettingsPush}
options={({ navigation }: any) => ({
headerTitle: t('meSettingsNotification:heading'),
headerTitle: t('meSettingsPush:heading'),
...(Platform.OS === 'android' && {
headerCenter: () => (
<HeaderCenter content={t('meSettingsNotification:heading')} />
<HeaderCenter content={t('meSettingsPush:heading')} />
)
}),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />

View File

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

View File

@ -1,9 +1,19 @@
import Timeline from '@components/Timeline'
import React from 'react'
import TimelineConversation from '@components/Timeline/Conversation'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
const ScreenMeConversations = React.memo(
() => {
return <Timeline page='Conversations' />
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Conversations' }]
const renderItem = useCallback(
({ item }) => (
<TimelineConversation conversation={item} queryKey={queryKey} />
),
[]
)
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
},
() => true
)

View File

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

View File

@ -1,37 +0,0 @@
import { MenuContainer, MenuRow } from '@components/Menu'
import { usePushQuery } from '@utils/queryHooks/push'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView } from 'react-native-gesture-handler'
import { useDispatch } from 'react-redux'
const ScreenMeSettingsNotification: React.FC = () => {
const { t } = useTranslation('meSettingsNotification')
const dispatch = useDispatch()
const { data, isLoading } = usePushQuery({})
return (
<ScrollView>
<MenuContainer>
<MenuRow
title={t('content.global.heading')}
description={t('content.global.description')}
// switchValue={notification.enabled}
// switchOnValueChange={() => dispatch(updateNotification(true))}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('content.follow.heading')}
loading={isLoading}
// switchDisabled={!notification.enabled}
// switchValue={notification.enabled ? data?.alerts.follow : false}
// switchOnValueChange={() => dispatch(updateNotification(true))}
/>
</MenuContainer>
</ScrollView>
)
}
export default ScreenMeSettingsNotification

View File

@ -0,0 +1,90 @@
import { MenuContainer, MenuRow } from '@components/Menu'
import { updateInstancePush } from '@utils/slices/instances/updatePush'
import { updateInstancePushAlert } from '@utils/slices/instances/updatePushAlert'
import { updateInstancePushDecode } from '@utils/slices/instances/updatePushDecode'
import { getInstancePush } from '@utils/slices/instancesSlice'
import * as WebBrowser from 'expo-web-browser'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView } from 'react-native-gesture-handler'
import { useDispatch, useSelector } from 'react-redux'
const ScreenMeSettingsPush: React.FC = () => {
const { t } = useTranslation('meSettingsPush')
const dispatch = useDispatch()
const instancePush = useSelector(getInstancePush)
const isLoading = instancePush?.global.loading || instancePush?.decode.loading
const alerts = useMemo(() => {
return instancePush?.alerts
? (['follow', 'favourite', 'reblog', 'mention', 'poll'] as [
'follow',
'favourite',
'reblog',
'mention',
'poll'
]).map(alert => (
<MenuRow
key={alert}
title={t(`content.${alert}.heading`)}
switchDisabled={!instancePush.global.value || isLoading}
switchValue={instancePush?.alerts[alert].value}
switchOnValueChange={() =>
dispatch(
updateInstancePushAlert({
changed: alert,
alerts: {
...instancePush.alerts,
[alert]: {
...instancePush?.alerts[alert],
value: !instancePush?.alerts[alert].value
}
}
})
)
}
/>
))
: null
}, [instancePush?.global, instancePush?.alerts, isLoading])
return (
<ScrollView>
<MenuContainer>
<MenuRow
title={t('content.global.heading')}
description={t('content.global.description')}
loading={instancePush?.global.loading}
switchDisabled={isLoading}
switchValue={instancePush?.global.value}
switchOnValueChange={() =>
dispatch(updateInstancePush(!instancePush?.global.value))
}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('content.decode.heading')}
description={t('content.decode.description')}
loading={instancePush?.decode.loading}
switchDisabled={!instancePush?.global.value || isLoading}
switchValue={instancePush?.decode.value}
switchOnValueChange={() =>
dispatch(updateInstancePushDecode(!instancePush?.decode.value))
}
/>
<MenuRow
title={t('content.howitworks')}
iconBack='ExternalLink'
onPress={() =>
WebBrowser.openBrowserAsync('https://tooot.app/how-push-works')
}
/>
</MenuContainer>
<MenuContainer>{alerts}</MenuContainer>
</ScrollView>
)
}
export default ScreenMeSettingsPush

View File

@ -1,5 +1,6 @@
import Timeline from '@components/Timeline'
import { StackScreenProps } from '@react-navigation/stack'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React from 'react'
const ScreenMeListsList: React.FC<StackScreenProps<
@ -10,7 +11,9 @@ const ScreenMeListsList: React.FC<StackScreenProps<
params: { list }
}
}) => {
return <Timeline page='List' list={list} />
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'List', list }]
return <Timeline queryKey={queryKey} />
}
export default ScreenMeListsList

View File

@ -4,7 +4,10 @@ import { MenuContainer, MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { useNavigation } from '@react-navigation/native'
import i18n from '@root/i18n/i18n'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import {
getInstanceActive,
getInstancePush
} from '@utils/slices/instancesSlice'
import {
changeBrowser,
changeLanguage,
@ -29,15 +32,24 @@ const SettingsApp: React.FC = () => {
const settingsLanguage = useSelector(getSettingsLanguage)
const settingsTheme = useSelector(getSettingsTheme)
const settingsBrowser = useSelector(getSettingsBrowser)
const instancePush = useSelector(
getInstancePush,
(prev, next) => prev?.global.value === next?.global.value
)
return (
<MenuContainer>
{instanceActive !== -1 ? (
<MenuRow
title={t('content.notification.heading')}
title={t('content.push.heading')}
content={
instancePush?.global.value
? t('content.push.content.enabled')
: t('content.push.content.disabled')
}
iconBack='ChevronRight'
onPress={() => {
navigation.navigate('Tab-Me-Settings-Notification')
navigation.navigate('Tab-Me-Settings-Push')
}}
/>
) : null}

View File

@ -4,16 +4,29 @@ import { useActionSheet } from '@expo/react-native-action-sheet'
import { persistor } from '@root/store'
import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Text } from 'react-native'
import { useSelector } from 'react-redux'
const SettingsDev: React.FC = () => {
const { theme } = useTheme()
const { showActionSheetWithOptions } = useActionSheet()
const instanceActive = useSelector(getInstanceActive)
const instances = useSelector(getInstances)
const instances = useSelector(getInstances, () => true)
return (
<MenuContainer>
<Text
selectable
style={{
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
...StyleConstants.FontStyle.S,
color: theme.primary
}}
>
{instances[instanceActive].token}
</Text>
<MenuRow
title={'Local active index'}
content={typeof instanceActive + ' - ' + instanceActive}

View File

@ -1,6 +1,8 @@
import { HeaderCenter } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineNotifications from '@components/Timeline/Notifications'
import sharedScreens from '@screens/Tabs/Shared/sharedScreens'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { updateInstanceNotification } from '@utils/slices/instancesSlice'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -28,11 +30,20 @@ const TabNotifications = React.memo(
}),
[]
)
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Notifications' }]
const renderItem = useCallback(
({ item }) => (
<TimelineNotifications notification={item} queryKey={queryKey} />
),
[]
)
const children = useCallback(
({ navigation }) => (
<Timeline
page='Notifications'
queryKey={queryKey}
customProps={{
renderItem,
viewabilityConfigCallbackPairs: [
{
onViewableItemsChanged: ({

View File

@ -1,9 +1,11 @@
import analytics from '@components/analytics'
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import SegmentedControl from '@react-native-community/segmented-control'
import { useNavigation } from '@react-navigation/native'
import sharedScreens from '@screens/Tabs/Shared/sharedScreens'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useState } from 'react'
@ -24,9 +26,18 @@ const TabPublic = React.memo(
const instanceActive = useSelector(getInstanceActive)
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 pages: {
title: string
key: App.Pages
}[] = [
{
title: t('public:heading.segments.left'),
key: 'LocalPublic'
},
{
title: t('public:heading.segments.right'),
key: 'Local'
}
]
const screenOptions = useMemo(
() => ({
@ -52,7 +63,7 @@ const TabPublic = React.memo(
<HeaderRight
content='Search'
onPress={() => {
analytics('search_tap', { page: pages[segment].page })
analytics('search_tap', { page: pages[segment].key })
navigation.navigate('Tab-Public', { screen: 'Tab-Shared-Search' })
}}
/>
@ -61,37 +72,42 @@ const TabPublic = React.memo(
[mode, segment, i18n.language]
)
const routes = pages.map(p => ({ key: p.page }))
const routes = pages.map(p => ({ key: p.key }))
const renderPager = useCallback(
props => <ViewPagerAdapter {...props} />,
[]
)
const renderScene = useCallback(
({
route
route: { key: page }
}: {
route: {
key: App.Pages
}
}) => {
return instanceActive !== -1 && <Timeline page={route.key} />
const queryKey: QueryKeyTimeline = ['Timeline', { page }]
const renderItem = ({ item }) => (
<TimelineDefault item={item} queryKey={queryKey} />
)
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
},
[instanceActive]
[]
)
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]
() =>
instanceActive !== -1 ? (
<TabView
lazy
swipeEnabled
renderPager={renderPager}
renderScene={renderScene}
renderTabBar={() => null}
onIndexChange={index => setSegment(index)}
navigationState={{ index: segment, routes }}
initialLayout={{ width: Dimensions.get('screen').width }}
/>
) : null,
[segment, instanceActive]
)
return (

View File

@ -1,7 +1,9 @@
import analytics from '@components/analytics'
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import { useAccountQuery } from '@utils/queryHooks/account'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useMemo, useReducer } from 'react'
import { StyleSheet, View } from 'react-native'
@ -67,15 +69,24 @@ const TabSharedAccount: React.FC<SharedAccountProp> = ({
)
}, [data])
const queryKey: QueryKeyTimeline = [
'Timeline',
{ page: 'Account_Default', account: account.id }
]
const renderItem = useCallback(
({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
return (
<AccountContext.Provider value={{ accountState, accountDispatch }}>
<AccountNav scrollY={scrollY} account={data} />
<Timeline
page='Account_Default'
account={account.id}
queryKey={queryKey}
disableRefresh
customProps={{
renderItem,
onScroll,
scrollEventThrottle: 16,
ListHeaderComponent

View File

@ -63,7 +63,7 @@ const AccountInformationAccount: React.FC<Props> = ({ account, myInfo }) => {
]}
selectable
>
@{myInfo ? instanceAccount.acct : account?.acct}
@{myInfo ? instanceAccount?.acct : account?.acct}
{myInfo ? `@${instanceUri}` : null}
</Text>
{movedContent}

View File

@ -1,5 +1,7 @@
import Timeline from '@components/Timeline'
import React from 'react'
import TimelineDefault from '@components/Timeline/Default'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
import { SharedAttachmentsProp } from './sharedScreens'
const TabSharedAttachments: React.FC<SharedAttachmentsProp> = ({
@ -7,7 +9,15 @@ const TabSharedAttachments: React.FC<SharedAttachmentsProp> = ({
params: { account }
}
}) => {
return <Timeline page='Account_Attachments' account={account.id} />
const queryKey: QueryKeyTimeline = [
'Timeline',
{ page: 'Account_Attachments', account: account.id }
]
const renderItem = useCallback(
({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
}
export default TabSharedAttachments

View File

@ -1,5 +1,7 @@
import Timeline from '@components/Timeline'
import React from 'react'
import TimelineDefault from '@components/Timeline/Default'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
import { SharedHashtagProp } from './sharedScreens'
const TabSharedHashtag: React.FC<SharedHashtagProp> = ({
@ -7,7 +9,12 @@ const TabSharedHashtag: React.FC<SharedHashtagProp> = ({
params: { hashtag }
}
}) => {
return <Timeline page='Hashtag' hashtag={hashtag} />
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Hashtag', hashtag }]
const renderItem = useCallback(
({ item }) => <TimelineDefault item={item} queryKey={queryKey} />,
[]
)
return <Timeline queryKey={queryKey} customProps={{ renderItem }} />
}
export default TabSharedHashtag

View File

@ -1,9 +1,10 @@
import ComponentAccount from '@components/Account'
import ComponentSeparator from '@components/Separator'
import TimelineEmpty from '@components/Timeline/Empty'
import TimelineEnd from '@components/Timeline/End'
import { useScrollToTop } from '@react-navigation/native'
import { useRelationshipsQuery } from '@utils/queryHooks/relationships'
import {
QueryKeyRelationships,
useRelationshipsQuery
} from '@utils/queryHooks/relationships'
import React, { useCallback, useMemo, useRef } from 'react'
import { RefreshControl, StyleSheet } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
@ -14,17 +15,15 @@ export interface Props {
}
const RelationshipsList: React.FC<Props> = ({ id, type }) => {
const queryKey: QueryKeyRelationships = ['Relationships', { type, id }]
const {
status,
data,
isFetching,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useRelationshipsQuery({
type,
id,
...queryKey[1],
options: {
getPreviousPageParam: firstPage =>
firstPage.links?.prev && { since_id: firstPage.links.next },
@ -41,18 +40,10 @@ const RelationshipsList: React.FC<Props> = ({ id, type }) => {
({ item }) => <ComponentAccount account={item} origin='relationship' />,
[]
)
const flItemEmptyComponent = useMemo(
() => <TimelineEmpty status={status} refetch={refetch} />,
[status]
)
const onEndReached = useCallback(
() => !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const ListFooterComponent = useCallback(
() => <TimelineEnd hasNextPage={hasNextPage} />,
[hasNextPage]
)
const refreshControl = useMemo(
() => (
<RefreshControl refreshing={isFetching} onRefresh={() => refetch()} />
@ -74,8 +65,6 @@ const RelationshipsList: React.FC<Props> = ({ id, type }) => {
onEndReached={onEndReached}
keyExtractor={keyExtractor}
onEndReachedThreshold={0.75}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={flItemEmptyComponent}
refreshControl={refreshControl}
ItemSeparatorComponent={ComponentSeparator}
maintainVisibleContentPosition={{

View File

@ -1,5 +1,11 @@
import Timeline from '@components/Timeline'
import React from 'react'
import TimelineDefault from '@components/Timeline/Default'
import { useNavigation } from '@react-navigation/native'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { findIndex } from 'lodash'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { FlatList } from 'react-native'
import { InfiniteQueryObserver, useQueryClient } from 'react-query'
import { SharedTootProp } from './sharedScreens'
const TabSharedToot: React.FC<SharedTootProp> = ({
@ -7,11 +13,73 @@ const TabSharedToot: React.FC<SharedTootProp> = ({
params: { toot, rootQueryKey }
}
}) => {
const queryKey: QueryKeyTimeline = [
'Timeline',
{ page: 'Toot', toot: toot.id }
]
const flRef = useRef<FlatList>(null)
const [testState, setTestState] = useState(false)
const scrolled = useRef(false)
const navigation = useNavigation()
const queryClient = useQueryClient()
const observer = new InfiniteQueryObserver(queryClient, { queryKey })
useEffect(() => {
const unsubscribe = observer.subscribe(result => {
if (result.isSuccess) {
setTestState(true)
const flattenData = result.data?.pages
? // @ts-ignore
result.data.pages.flatMap(d => [...d.body])
: []
// Auto go back when toot page is empty
if (flattenData.length === 0) {
navigation.goBack()
}
if (!scrolled.current) {
scrolled.current = true
const pointer = findIndex(flattenData, ['id', toot.id])
setTimeout(() => {
flRef.current?.scrollToIndex({
index: pointer,
viewOffset: 100
})
}, 500)
}
}
})
return () => unsubscribe()
}, [])
// Toot page auto scroll to selected toot
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 renderItem = useCallback(
({ item }) => (
<TimelineDefault
item={item}
queryKey={queryKey}
rootQueryKey={rootQueryKey}
highlighted={toot.id === item.id}
/>
),
[]
)
return (
<Timeline
page='Toot'
toot={toot.id}
rootQueryKey={rootQueryKey}
flRef={flRef}
queryKey={queryKey}
customProps={{ renderItem, ...(testState && onScrollToIndexFailed) }}
disableRefresh
disableInfinity
/>

15
src/startup/push.ts Normal file
View File

@ -0,0 +1,15 @@
import * as Notifications from 'expo-notifications'
import log from './log'
const push = () => {
log('log', 'Push', 'initializing')
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: false
})
})
}
export default push

View File

@ -1,24 +0,0 @@
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Push']
const queryFunction = async () => {
const res = await apiInstance<Mastodon.PushSubscription>({
method: 'get',
url: 'push/subscription'
})
return res.body
}
const usePushQuery = <TData = Mastodon.PushSubscription>({
options
}: {
options?: UseQueryOptions<Mastodon.PushSubscription, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Push']
return useQuery(queryKey, queryFunction, options)
}
export { usePushQuery }

View File

@ -2,7 +2,7 @@ import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import { useInfiniteQuery, UseInfiniteQueryOptions } from 'react-query'
export type QueryKey = [
export type QueryKeyRelationships = [
'Relationships',
{ type: 'following' | 'followers'; id: Mastodon.Account['id'] }
]
@ -11,7 +11,7 @@ const queryFunction = ({
queryKey,
pageParam
}: {
queryKey: QueryKey
queryKey: QueryKeyRelationships
pageParam?: { [key: string]: string }
}) => {
const { type, id } = queryKey[1]
@ -27,7 +27,7 @@ const queryFunction = ({
const useRelationshipsQuery = <TData = Mastodon.Account[]>({
options,
...queryKeyParams
}: QueryKey[1] & {
}: QueryKeyRelationships[1] & {
options?: UseInfiniteQueryOptions<
{
body: Mastodon.Account[]
@ -37,7 +37,10 @@ const useRelationshipsQuery = <TData = Mastodon.Account[]>({
TData
>
}) => {
const queryKey: QueryKey = ['Relationships', { ...queryKeyParams }]
const queryKey: QueryKeyRelationships = [
'Relationships',
{ ...queryKeyParams }
]
return useInfiniteQuery(queryKey, queryFunction, options)
}

View File

@ -362,7 +362,7 @@ const useTimelineMutation = ({
let oldData
params.queryKey && (oldData = queryClient.getQueryData(params.queryKey))
haptics('Success')
haptics('Light')
switch (params.type) {
case 'updateStatusProperty':
updateStatusProperty({ queryClient, ...params })

View File

@ -24,7 +24,10 @@ const updateStatus = ({
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
if (
typeof payload.currentValue === 'boolean' &&
payload.currentValue
) {
item.reblog![payload.propertyCount] = payload.countValue - 1
} else {
item.reblog![payload.propertyCount] = payload.countValue + 1
@ -36,7 +39,10 @@ const updateStatus = ({
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
if (
typeof payload.currentValue === 'boolean' &&
payload.currentValue
) {
item[payload.propertyCount] = payload.countValue - 1
} else {
item[payload.propertyCount] = payload.countValue + 1

View File

@ -75,8 +75,16 @@ const addInstance = createAsyncThunk(
latestTime: undefined
},
push: {
loading: false,
enabled: false
global: { loading: false, value: false },
decode: { loading: false, value: false },
alerts: {
follow: { loading: false, value: true },
favourite: { loading: false, value: true },
reblog: { loading: false, value: true },
mention: { loading: false, value: true },
poll: { loading: false, value: true }
},
keys: undefined
},
drafts: []
}

View File

@ -1,21 +0,0 @@
import apiGeneral from '@api/general'
import * as Notifications from 'expo-notifications'
const serverUnregister = async () => {
const deviceToken = (await Notifications.getDevicePushTokenAsync()).data
return apiGeneral<{ endpoint: string; publicKey: string; auth: string }>({
method: 'post',
domain: 'testpush.home.xmflsct.com',
url: 'unregister',
body: { deviceToken }
})
}
const pushDisable = async () => {
await serverUnregister()
return false
}
export default pushDisable

View File

@ -1,61 +0,0 @@
import apiGeneral from '@api/general'
import apiInstance from '@api/instance'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
const serverRegister = async () => {
const deviceToken = (await Notifications.getDevicePushTokenAsync()).data
const expoToken = (
await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot'
})
).data
return apiGeneral<{ endpoint: string; publicKey: string; auth: string }>({
method: 'post',
domain: 'testpush.home.xmflsct.com',
url: 'register',
body: { deviceToken, expoToken }
})
}
const pushEnable = async (): Promise<Mastodon.PushSubscription> => {
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
alert('Failed to get push token for push notification!')
return Promise.reject()
}
const serverRes = (await serverRegister()).body
const formData = new FormData()
formData.append(
'subscription[endpoint]',
'https://testpush.home.xmflsct.com/test1'
)
formData.append('subscription[keys][p256dh]', serverRes.publicKey)
formData.append('subscription[keys][auth]', serverRes.auth)
const res = await apiInstance<Mastodon.PushSubscription>({
method: 'post',
url: 'push/subscription',
body: formData
})
return res.body
// if (Platform.OS === 'android') {
// Notifications.setNotificationChannelAsync('default', {
// name: 'default',
// importance: Notifications.AndroidImportance.MAX,
// vibrationPattern: [0, 250, 250, 250],
// lightColor: '#FF231F7C'
// })
// }
}
export default pushEnable

View File

@ -0,0 +1,127 @@
import apiGeneral from '@api/general'
import apiInstance from '@api/instance'
import { RootState } from '@root/store'
import {
getInstance,
Instance,
PUSH_SERVER
} from '@utils/slices/instancesSlice'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
const register1 = async ({
expoToken,
instanceUrl,
accountId,
accountFull
}: {
expoToken: string
instanceUrl: string
accountId: Mastodon.Account['id']
accountFull: string
}) => {
return apiGeneral<{
endpoint: string
keys: { public: string; private: string; auth: string }
}>({
method: 'post',
domain: PUSH_SERVER,
url: 'v1/register1',
body: { expoToken, instanceUrl, accountId, accountFull }
})
}
const register2 = async ({
expoToken,
serverKey,
instanceUrl,
accountId,
removeKeys
}: {
expoToken: string
serverKey: Mastodon.PushSubscription['server_key']
instanceUrl: string
accountId: Mastodon.Account['id']
removeKeys: boolean
}) => {
return apiGeneral({
method: 'post',
domain: PUSH_SERVER,
url: 'v1/register2',
body: { expoToken, instanceUrl, accountId, serverKey, removeKeys }
})
}
const pushRegister = async (
state: RootState,
expoToken: string
): Promise<Instance['push']['keys']> => {
const instance = getInstance(state)
const instanceUrl = instance?.url
const instanceUri = instance?.uri
const instanceAccount = instance?.account
const instancePush = instance?.push
if (!instanceUrl || !instanceUri || !instanceAccount || !instancePush) {
return Promise.reject()
}
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') {
alert('Failed to get push token for push notification!')
return Promise.reject()
}
const accountId = instanceAccount.id
const accountFull = `@${instanceAccount.acct}@${instanceUri}`
const serverRes = await register1({
expoToken,
instanceUrl,
accountId,
accountFull
})
console.log('endpoint', serverRes.body.endpoint)
console.log('token', instance?.token)
const alerts = instancePush.alerts
const formData = new FormData()
formData.append('subscription[endpoint]', serverRes.body.endpoint)
formData.append('subscription[keys][p256dh]', serverRes.body.keys.public)
formData.append('subscription[keys][auth]', serverRes.body.keys.auth)
Object.keys(alerts).map(key =>
// @ts-ignore
formData.append(`data[alerts][${key}]`, alerts[key].value.toString())
)
const res = await apiInstance<Mastodon.PushSubscription>({
method: 'post',
url: 'push/subscription',
body: formData
})
await register2({
expoToken,
serverKey: res.body.server_key,
instanceUrl,
accountId,
removeKeys: instancePush.decode.value === false
})
return Promise.resolve(serverRes.body.keys)
// if (Platform.OS === 'android') {
// Notifications.setNotificationChannelAsync('default', {
// name: 'default',
// importance: Notifications.AndroidImportance.MAX,
// vibrationPattern: [0, 250, 250, 250],
// lightColor: '#FF231F7C'
// })
// }
}
export default pushRegister

View File

@ -0,0 +1,32 @@
import apiGeneral from '@api/general'
import apiInstance from '@api/instance'
import { RootState } from '@root/store'
import { getInstance, PUSH_SERVER } from '@utils/slices/instancesSlice'
const pushUnregister = async (state: RootState, expoToken: string) => {
const instance = getInstance(state)
if (!instance?.url || !instance.account.id) {
return Promise.reject()
}
await apiInstance<{}>({
method: 'delete',
url: 'push/subscription'
})
await apiGeneral<{ endpoint: string; publicKey: string; auth: string }>({
method: 'post',
domain: PUSH_SERVER,
url: 'v1/unregister',
body: {
expoToken,
instanceUrl: instance.url,
accountId: instance.account.id
}
})
return
}
export default pushUnregister

View File

@ -1,17 +1,27 @@
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import * as Notifications from 'expo-notifications'
import { Instance } from '../instancesSlice'
import pushDisable from './push/disable'
import pushEnable from './push/enable'
import pushRegister from './push/register'
import pushUnregister from './push/unregister'
export const updatePush = createAsyncThunk(
export const updateInstancePush = createAsyncThunk(
'instances/updatePush',
async (
enable: boolean
): Promise<Instance['push']['subscription'] | boolean> => {
if (enable) {
return pushEnable()
disable: boolean,
{ getState }
): Promise<Instance['push']['keys'] | undefined> => {
const state = getState() as RootState
const expoToken = (
await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot'
})
).data
if (disable) {
return await pushRegister(state, expoToken)
} else {
return pushDisable()
return await pushUnregister(state, expoToken)
}
}
)

View File

@ -0,0 +1,27 @@
import apiInstance from '@api/instance'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { Instance } from '../instancesSlice'
export const updateInstancePushAlert = createAsyncThunk(
'instances/updatePushAlert',
async ({
alerts
}: {
changed: keyof Instance['push']['alerts']
alerts: Instance['push']['alerts']
}): Promise<Instance['push']['alerts']> => {
const formData = new FormData()
Object.keys(alerts).map(alert =>
// @ts-ignore
formData.append(`data[alerts][${alert}]`, alerts[alert].value.toString())
)
await apiInstance<Mastodon.PushSubscription>({
method: 'put',
url: 'push/subscription',
body: formData
})
return Promise.resolve(alerts)
}
)

View File

@ -0,0 +1,39 @@
import apiGeneral from '@api/general'
import { createAsyncThunk } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import * as Notifications from 'expo-notifications'
import { getInstance, Instance, PUSH_SERVER } from '../instancesSlice'
export const updateInstancePushDecode = createAsyncThunk(
'instances/updatePushDecode',
async (
disalbe: boolean,
{ getState }
): Promise<Instance['push']['decode']['value']> => {
const state = getState() as RootState
const instance = getInstance(state)
if (!instance?.url || !instance.account.id || !instance.push.keys) {
return Promise.reject()
}
const expoToken = (
await Notifications.getExpoPushTokenAsync({
experienceId: '@xmflsct/tooot'
})
).data
await apiGeneral({
method: 'post',
domain: PUSH_SERVER,
url: 'v1/update-decode',
body: {
expoToken,
instanceUrl: instance.url,
accountId: instance.account.id,
...(disalbe && { keys: instance.push.keys })
}
})
return Promise.resolve(disalbe)
}
)

View File

@ -6,7 +6,11 @@ import { findIndex } from 'lodash'
import addInstance from './instances/add'
import removeInstance from './instances/remove'
import { updateAccountPreferences } from './instances/updateAccountPreferences'
import { updatePush } from './instances/updatePush'
import { updateInstancePush } from './instances/updatePush'
import { updateInstancePushAlert } from './instances/updatePushAlert'
import { updateInstancePushDecode } from './instances/updatePushDecode'
export const PUSH_SERVER = __DEV__ ? 'testpush.tooot.app' : 'push.tooot.app'
export type Instance = {
active: boolean
@ -29,11 +33,65 @@ export type Instance = {
readTime?: Mastodon.Notification['created_at']
latestTime?: Mastodon.Notification['created_at']
}
push: {
loading: boolean
enabled: boolean
subscription?: Mastodon.PushSubscription
}
push:
| {
global: { loading: boolean; value: true }
decode: { loading: boolean; value: boolean }
alerts: {
follow: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow']
}
favourite: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['favourite']
}
reblog: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['reblog']
}
mention: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['mention']
}
poll: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['poll']
}
}
keys: {
auth: string
public: string
private: string
}
}
| {
global: { loading: boolean; value: false }
decode: { loading: boolean; value: boolean }
alerts: {
follow: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['follow']
}
favourite: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['favourite']
}
reblog: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['reblog']
}
mention: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['mention']
}
poll: {
loading: boolean
value: Mastodon.PushSubscription['alerts']['poll']
}
}
keys: undefined
}
drafts: ComposeStateDraft[]
}
@ -119,7 +177,6 @@ const instancesSlice = createSlice({
state.instances.push(action.payload.data)
break
case 'overwrite':
console.log('overwriting')
state.instances = state.instances.map(instance => {
if (
instance.url === action.payload.data.url &&
@ -152,6 +209,7 @@ const instancesSlice = createSlice({
console.error(action.error)
})
// Update Instance Account Preferences
.addCase(updateAccountPreferences.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].account.preferences = action.payload
@ -160,14 +218,56 @@ const instancesSlice = createSlice({
console.error(action.error)
})
.addCase(updatePush.fulfilled, (state, action) => {
// Update Instance Push Global
.addCase(updateInstancePush.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
if (typeof action.payload === 'boolean') {
state.instances[activeIndex].push.enabled = action.payload
} else {
state.instances[activeIndex].push.enabled = true
state.instances[activeIndex].push.subscription = action.payload
}
state.instances[activeIndex].push.global.loading = false
state.instances[activeIndex].push.global.value = action.meta.arg
state.instances[activeIndex].push.keys = action.payload
})
.addCase(updateInstancePush.rejected, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.global.loading = false
})
.addCase(updateInstancePush.pending, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.global.loading = true
})
// Update Instance Push Decode
.addCase(updateInstancePushDecode.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = false
state.instances[activeIndex].push.decode.value = action.payload
})
.addCase(updateInstancePushDecode.rejected, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = false
})
.addCase(updateInstancePushDecode.pending, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = true
})
// Update Instance Push Individual Alert
.addCase(updateInstancePushAlert.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[
action.meta.arg.changed
].loading = false
state.instances[activeIndex].push.alerts = action.payload
})
.addCase(updateInstancePushAlert.rejected, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[
action.meta.arg.changed
].loading = false
})
.addCase(updateInstancePushAlert.pending, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[
action.meta.arg.changed
].loading = true
})
}
})
@ -217,6 +317,11 @@ export const getInstanceNotification = ({
return instanceActive !== -1 ? instances[instanceActive].notification : null
}
export const getInstancePush = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].push : null
}
export const getInstanceDrafts = ({ instances: { instances } }: RootState) => {
const instanceActive = findInstanceActive(instances)
return instanceActive !== -1 ? instances[instanceActive].drafts : null

View File

@ -968,6 +968,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.6.2":
version "7.13.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.7.tgz#d494e39d198ee9ca04f4dcb76d25d9d7a1dc961a"
integrity sha512-h+ilqoX998mRVM5FtB5ijRuHUDVt5l3yfoOi2uh18Z/O3hvyaHQ39NpxVkCIG5yFs+mLq/ewFp8Bss6zmWv6ZA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.0.0", "@babel/template@^7.10.4", "@babel/template@^7.12.7", "@babel/template@^7.3.3", "@babel/template@^7.8.6":
version "7.12.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc"
@ -3247,7 +3254,7 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
big-integer@^1.6.44:
big-integer@^1.6.16, big-integer@^1.6.44:
version "1.6.48"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e"
integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==
@ -3319,6 +3326,19 @@ braces@^3.0.1:
dependencies:
fill-range "^7.0.1"
broadcast-channel@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-3.4.1.tgz#65b63068d0a5216026a19905c9b2d5e9adf0928a"
integrity sha512-VXYivSkuBeQY+pL5hNQQNvBdKKQINBAROm4G8lAbWQfOZ7Yn4TMcgLNlJyEqlkxy5G8JJBsI3VJ1u8FUTOROcg==
dependencies:
"@babel/runtime" "^7.7.2"
detect-node "^2.0.4"
js-sha3 "0.8.0"
microseconds "0.2.0"
nano-time "1.0.0"
rimraf "3.0.2"
unload "2.2.0"
browser-process-hrtime@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"
@ -4054,6 +4074,11 @@ detect-newline@^3.0.0:
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
detect-node@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
diff-sequences@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5"
@ -6775,6 +6800,11 @@ jpeg-js@^0.4.0:
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.3.tgz#6158e09f1983ad773813704be80680550eff977b"
integrity sha512-ru1HWKek8octvUHFHvE5ZzQ1yAsJmIvRdGWvSoKV52XKyuyYA437QWDttXT8eZXDSbuMpHlLzPDZUPd6idIz+Q==
js-sha3@0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840"
integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@ -7631,6 +7661,11 @@ micromatch@^4.0.2:
braces "^3.0.1"
picomatch "^2.0.5"
microseconds@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39"
integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==
mime-db@1.45.0, "mime-db@>= 1.43.0 < 2":
version "1.45.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea"
@ -7739,6 +7774,13 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
nano-time@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef"
integrity sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=
dependencies:
big-integer "^1.6.16"
nanoid@^3.1.15:
version "3.1.20"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
@ -8790,12 +8832,13 @@ react-navigation@*, react-navigation@^4.4.3:
"@react-navigation/core" "^3.7.9"
"@react-navigation/native" "^3.8.3"
react-query@^3.9.7:
version "3.9.7"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.9.7.tgz#324c697f418827c129c8c126d233c6052bb1e35e"
integrity sha512-vpQgRFOljd7Lr1wL8hOwxWzb7awLIjaeqaq6DJ1fzA8N9mK1fAkK+UVrt8WaXJGBfz7JEnfCiXuENQspk0N7Sw==
react-query@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.12.0.tgz#a2082a167f3e394e84dfd3cec0f8c7503abf33dc"
integrity sha512-WJYECeZ6xT2oxIlgqXUjLNLWRvJbeelXscVnAFfyUFgO21OYEYHMWPG61V9W57EUUqrXioQsNPsU9XyddfEvXQ==
dependencies:
"@babel/runtime" "^7.5.5"
broadcast-channel "^3.4.1"
match-sorter "^6.0.2"
react-redux@^7.2.2:
@ -9132,6 +9175,13 @@ ret@~0.1.10:
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
rimraf@3.0.2, rimraf@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
dependencies:
glob "^7.1.3"
rimraf@^2.5.4, rimraf@^2.6.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
@ -9139,13 +9189,6 @@ rimraf@^2.5.4, rimraf@^2.6.1:
dependencies:
glob "^7.1.3"
rimraf@^3.0.0:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
dependencies:
glob "^7.1.3"
rimraf@~2.2.6:
version "2.2.8"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582"
@ -10215,6 +10258,14 @@ universalify@^2.0.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
unload@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/unload/-/unload-2.2.0.tgz#ccc88fdcad345faa06a92039ec0f80b488880ef7"
integrity sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==
dependencies:
"@babel/runtime" "^7.6.2"
detect-node "^2.0.4"
unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"