Lots of updates

This commit is contained in:
Zhiyuan Zheng 2021-01-07 19:13:09 +01:00
parent dcb36a682d
commit 4b99813bb7
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
104 changed files with 2463 additions and 1619 deletions

163
App.tsx
View File

@ -1,88 +1,38 @@
import NetInfo from '@react-native-community/netinfo'
import client from '@root/api/client'
import Index from '@root/Index'
import { persistor, store } from '@root/store'
import { resetLocal } from '@root/utils/slices/instancesSlice'
import ThemeManager from '@utils/styles/ThemeManager'
import chalk from 'chalk'
import * as Analytics from 'expo-firebase-analytics'
import { Audio } from 'expo-av'
import * as SplashScreen from 'expo-splash-screen'
import React, { useCallback, useEffect, useState } from 'react'
import { enableScreens } from 'react-native-screens'
import { onlineManager, QueryClient, QueryClientProvider } from 'react-query'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import * as Sentry from 'sentry-expo'
import checkSecureStorageVersion from '@root/startup/checkSecureStorageVersion'
import dev from '@root/startup/dev'
import sentry from '@root/startup/sentry'
import log from '@root/startup/log'
import audio from '@root/startup/audio'
import onlineStatus from '@root/startup/onlineStatus'
import netInfo from '@root/startup/netInfo'
const ctx = new chalk.Instance({ level: 3 })
const startingLog = (type: 'log' | 'warn' | 'error', message: string) => {
switch (type) {
case 'log':
console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message)
break
case 'warn':
console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message)
break
case 'error':
console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message)
break
}
}
dev()
sentry()
audio()
onlineStatus()
if (__DEV__) {
Analytics.setDebugModeEnabled(true)
startingLog('log', 'initializing wdyr')
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackHooks: true,
hotReloadBufferMs: 1000
})
}
startingLog('log', 'initializing Sentry')
Sentry.init({
dsn:
'https://c9e29aa05f774aca8f36def98244ce04@o389581.ingest.sentry.io/5571975',
enableInExpoDevelopment: false,
debug: __DEV__
})
startingLog('log', 'initializing react-query')
log('log', 'react-query', 'initializing')
const queryClient = new QueryClient()
startingLog('log', 'setting audio playback default options')
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
interruptionModeIOS: 1
})
startingLog('log', 'initializing native screen')
log('log', 'react-native-screens', 'initializing')
enableScreens()
const App: React.FC = () => {
startingLog('log', 'rendering App')
const [appLoaded, setAppLoaded] = useState(false)
log('log', 'App', 'rendering App')
const [localCorrupt, setLocalCorrupt] = useState<string>()
useEffect(() => {
const onlineState = onlineManager.setEventListener(setOnline => {
startingLog('log', 'added onlineManager listener')
return NetInfo.addEventListener(state => {
startingLog('warn', `setting online state ${state.isConnected}`)
// @ts-ignore
setOnline(state.isConnected)
})
})
return () => {
onlineState
}
}, [])
useEffect(() => {
const delaySplash = async () => {
startingLog('log', 'delay splash')
log('log', 'App', 'delay splash')
try {
await SplashScreen.preventAutoHideAsync()
} catch (e) {
@ -91,72 +41,29 @@ const App: React.FC = () => {
}
delaySplash()
}, [])
useEffect(() => {
const hideSplash = async () => {
startingLog('log', 'hide splash')
try {
await SplashScreen.hideAsync()
} catch (e) {
console.warn(e)
}
}
if (appLoaded) {
hideSplash()
}
}, [appLoaded])
const onBeforeLift = useCallback(() => {
NetInfo.fetch().then(netInfo => {
startingLog('log', 'on before lift')
const localUrl = store.getState().instances.local.url
const localToken = store.getState().instances.local.token
if (netInfo.isConnected) {
startingLog('log', 'network connected')
if (localUrl && localToken) {
startingLog('log', 'checking locally stored credentials')
client({
method: 'get',
instance: 'remote',
instanceDomain: localUrl,
url: `accounts/verify_credentials`,
headers: { Authorization: `Bearer ${localToken}` }
})
.then(res => {
startingLog('log', 'local credential check passed')
if (res.body.id !== store.getState().instances.local.account.id) {
store.dispatch(resetLocal())
setLocalCorrupt('')
}
setAppLoaded(true)
})
.catch(error => {
startingLog('error', 'local credential check failed')
if (
error.status &&
typeof error.status === 'number' &&
error.status === 401
) {
store.dispatch(resetLocal())
}
setLocalCorrupt(error.data.error)
setAppLoaded(true)
})
} else {
startingLog('log', 'no local credential found')
setAppLoaded(true)
}
} else {
startingLog('warn', 'network not connected')
setAppLoaded(true)
}
})
const onBeforeLift = useCallback(async () => {
const netInfoRes = await netInfo()
if (netInfoRes.corrupted && netInfoRes.corrupted.length) {
setLocalCorrupt(netInfoRes.corrupted)
}
log('log', 'App', 'hide splash')
try {
await SplashScreen.hideAsync()
return Promise.resolve()
} catch (e) {
console.warn(e)
return Promise.reject()
}
}, [])
const main = useCallback(
bootstrapped => {
startingLog('log', 'bootstrapped')
if (bootstrapped && appLoaded) {
startingLog('log', 'loading actual app :)')
log('log', 'App', 'bootstrapped')
if (bootstrapped) {
log('log', 'App', 'loading actual app :)')
require('@root/i18n/i18n')
return (
<ThemeManager>
@ -167,7 +74,7 @@ const App: React.FC = () => {
return null
}
},
[appLoaded]
[localCorrupt]
)
return (

Binary file not shown.

View File

@ -9,6 +9,7 @@
"test": "jest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^1.13.2",
"@react-native-community/masked-view": "0.1.10",
"@react-native-community/netinfo": "^5.9.7",
"@react-native-community/segmented-control": "2.2.1",
@ -116,4 +117,4 @@
]
},
"private": true
}
}

3
publish.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash
SENTRY_ORG=xmflsct SENTRY_PROJECT=mastodon-app SENTRY_AUTH_TOKEN=771a5746bad54f5987aa5d5d8b1c10abc751b3bf28554c908d452e0054eb6003 expo publish --release-channel testing

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

@ -15,40 +15,3 @@ declare namespace App {
| 'Bookmarks'
| 'Favourites'
}
declare namespace QueryKey {
type Account = ['Account', { id: Mastodon.Account['id'] }]
type Announcements = ['Announcements', { showAll?: boolean }]
type Application = ['Application', { instanceDomain: string }]
type Instance = ['Instance', { instanceDomain: string }]
type Relationship = ['Relationship', { id: Mastodon.Account['id'] }]
type Relationships = [
'Relationships',
'following' | 'followers',
{ id: Mastodon.Account['id'] }
]
type Search = [
'Search',
{
type?: 'accounts' | 'hashtags' | 'statuses'
term: string
limit?: number
}
]
type Timeline = [
Pages,
{
hashtag?: Mastodon.Tag['name']
list?: Mastodon.List['id']
toot?: Mastodon.Status['id']
account?: Mastodon.Account['id']
}
]
}

View File

@ -67,7 +67,7 @@ declare namespace Mastodon {
vapid_key?: string
}
type AppOauth = {
type Apps = {
id: string
name: string
website?: string

View File

@ -11,39 +11,38 @@ import ScreenLocal from '@screens/Local'
import ScreenMe from '@screens/Me'
import ScreenNotifications from '@screens/Notifications'
import ScreenPublic from '@screens/Public'
import { timelineFetch } from '@utils/fetches/timelineFetch'
import hookTimeline from '@utils/queryHooks/timeline'
import {
getLocalActiveIndex,
getLocalNotification,
getLocalUrl,
updateLocalAccountPreferences,
updateNotification
localUpdateAccountPreferences,
localUpdateNotification
} from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes'
import * as Analytics from 'expo-firebase-analytics'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import React, {
createRef,
useCallback,
useEffect,
useMemo,
useRef
} from 'react'
import { StatusBar } from 'react-native'
import Toast from 'react-native-toast-message'
import { useInfiniteQuery } from 'react-query'
import { useDispatch, useSelector } from 'react-redux'
const Tab = createBottomTabNavigator<RootStackParamList>()
export type RootStackParamList = {
'Screen-Local': undefined
'Screen-Public': { publicTab: boolean }
'Screen-Post': undefined
'Screen-Notifications': undefined
'Screen-Me': undefined
}
const Tab = createBottomTabNavigator()
export interface Props {
localCorrupt?: string
}
export const navigationRef = createRef<NavigationContainerRef>()
const Index: React.FC<Props> = ({ localCorrupt }) => {
const dispatch = useDispatch()
const localInstance = useSelector(getLocalUrl)
const localActiveIndex = useSelector(getLocalActiveIndex)
const { mode, theme } = useTheme()
enum barStyle {
light = 'dark-content',
@ -51,7 +50,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
}
const routeNameRef = useRef<string | undefined>()
const navigationRef = useRef<NavigationContainerRef>(null)
// const navigationRef = useRef<NavigationContainerRef>(null)
// const isConnected = useNetInfo().isConnected
// const [firstRender, setFirstRender] = useState(false)
@ -82,14 +81,14 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
// On launch check if there is any unread announcements
useEffect(() => {
console.log('Checking announcements')
localInstance &&
client({
localActiveIndex !== null &&
client<Mastodon.Announcement[]>({
method: 'get',
instance: 'local',
url: `announcements`
})
.then(({ body }: { body?: Mastodon.Announcement[] }) => {
if (body?.filter(announcement => !announcement.read).length) {
.then(res => {
if (res?.filter(announcement => !announcement.read).length) {
navigationRef.current?.navigate('Screen-Shared-Announcements', {
showAll: false
})
@ -99,15 +98,15 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
}, [])
// On launch check if there is any unread noficiations
const queryNotification = useInfiniteQuery(
['Notifications', {}],
timelineFetch,
{
enabled: localInstance ? true : false,
const queryNotification = hookTimeline({
page: 'Notifications',
options: {
enabled: localActiveIndex !== null ? true : false,
refetchInterval: 1000 * 60,
refetchIntervalInBackground: true
}
)
})
const prevNotification = useSelector(getLocalNotification)
useEffect(() => {
if (queryNotification.data?.pages) {
@ -120,7 +119,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
if (!prevNotification || !prevNotification.latestTime) {
dispatch(
updateNotification({
localUpdateNotification({
unread: true
})
)
@ -129,7 +128,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
new Date(prevNotification.latestTime) < new Date(latestNotificationTime)
) {
dispatch(
updateNotification({
localUpdateNotification({
unread: true,
latestTime: latestNotificationTime
})
@ -140,8 +139,8 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
// Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => {
if (localInstance) {
dispatch(updateLocalAccountPreferences())
if (localActiveIndex !== null) {
dispatch(localUpdateAccountPreferences())
}
}, [])
@ -204,51 +203,48 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
const tabNavigatorTabBarOptions = useMemo(
() => ({
activeTintColor: theme.primary,
inactiveTintColor: localInstance ? theme.secondary : theme.disabled,
inactiveTintColor:
localActiveIndex !== null ? theme.secondary : theme.disabled,
showLabel: false
}),
[theme, localInstance]
[theme, localActiveIndex]
)
const tabScreenLocalListeners = useCallback(
() => ({
tabPress: (e: any) => {
if (!localInstance) {
if (!(localActiveIndex !== null)) {
e.preventDefault()
}
}
}),
[localInstance]
[localActiveIndex]
)
const tabScreenComposeListeners = useMemo(
() => ({
tabPress: (e: any) => {
e.preventDefault()
if (localInstance) {
if (localActiveIndex !== null) {
haptics('Medium')
navigationRef.current?.navigate('Screen-Shared-Compose')
}
}
}),
[localInstance]
[localActiveIndex]
)
const tabScreenComposeComponent = useCallback(() => null, [])
const tabScreenNotificationsListeners = useCallback(
() => ({
tabPress: (e: any) => {
if (!localInstance) {
if (!(localActiveIndex !== null)) {
e.preventDefault()
}
}
}),
[localInstance]
[localActiveIndex]
)
const tabScreenNotificationsOptions = useMemo(
() => ({
tabBarBadge: prevNotification && prevNotification.unread ? '' : undefined,
tabBarBadgeStyle: {
transform: [{ scale: 0.5 }],
backgroundColor: theme.red
}
tabBarBadge: prevNotification && prevNotification.unread ? '' : undefined
}),
[theme, prevNotification]
)
@ -263,7 +259,9 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
onStateChange={navigationContainerOnStateChange}
>
<Tab.Navigator
initialRouteName={localInstance ? 'Screen-Local' : 'Screen-Public'}
initialRouteName={
localActiveIndex !== null ? 'Screen-Local' : 'Screen-Public'
}
screenOptions={tabNavigatorScreenOptions}
tabBarOptions={tabNavigatorTabBarOptions}
>
@ -282,7 +280,13 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
name='Screen-Notifications'
component={ScreenNotifications}
listeners={tabScreenNotificationsListeners}
options={tabScreenNotificationsOptions}
options={{
tabBarBadgeStyle: {
transform: [{ scale: 0.5 }],
backgroundColor: theme.red
},
...tabScreenNotificationsOptions
}}
/>
<Tab.Screen name='Screen-Me' component={ScreenMe} />
</Tab.Navigator>

View File

@ -1,11 +1,13 @@
import { RootState } from '@root/store'
import axios from 'axios'
import chalk from 'chalk'
const ctx = new chalk.Instance({ level: 3 })
const client = async ({
const client = async <T = unknown>({
method,
instance,
localIndex,
instanceDomain,
version = 'v1',
url,
@ -16,6 +18,7 @@ const client = async ({
}: {
method: 'get' | 'post' | 'put' | 'delete'
instance: 'local' | 'remote'
localIndex?: number
instanceDomain?: string
version?: 'v1' | 'v2'
url: string
@ -25,9 +28,30 @@ const client = async ({
headers?: { [key: string]: string }
body?: FormData
onUploadProgress?: (progressEvent: any) => void
}): Promise<any> => {
}): Promise<T> => {
const { store } = require('@root/store')
const state = (store.getState() as RootState).instances
const theLocalIndex =
localIndex !== undefined ? localIndex : state.local.activeIndex
let domain = null
if (instance === 'remote') {
domain = instanceDomain || state.remote.url
} else {
if (theLocalIndex !== null && state.local.instances[theLocalIndex]) {
domain = state.local.instances[theLocalIndex].url
} else {
console.error(
ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided'
)
return Promise.reject()
}
}
console.log(
ctx.bgGreen.bold(' API ') +
' ' +
domain +
' ' +
method +
ctx.green(' -> ') +
@ -35,10 +59,6 @@ const client = async ({
(params ? ctx.green(' -> ') : ''),
params ? params : ''
)
const { store } = require('@root/store')
const state = store.getState().instances
const domain =
instance === 'remote' ? instanceDomain || state.remote.url : state.local.url
return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 15,
@ -50,18 +70,13 @@ const client = async ({
'Content-Type': 'application/json',
...headers,
...(instance === 'local' && {
Authorization: `Bearer ${state.local.token}`
Authorization: `Bearer ${state.local!.instances[theLocalIndex!].token}`
})
},
...(body && { data: body }),
...(onUploadProgress && { onUploadProgress: onUploadProgress })
})
.then(response =>
Promise.resolve({
headers: response.headers,
body: response.data
})
)
.then(response => Promise.resolve(response.data))
.catch(error => {
if (error.response) {
// The request was made and the server responded with a status code
@ -70,7 +85,8 @@ const client = async ({
ctx.bold(' API '),
ctx.bold('response'),
error.response.status,
error.response.data.error
error.response.data.error,
error.request
)
return Promise.reject(error.response)
} else if (error.request) {

View File

@ -23,6 +23,7 @@ export interface Props {
loading?: boolean
destructive?: boolean
disabled?: boolean
active?: boolean
strokeWidth?: number
size?: 'S' | 'M' | 'L'
@ -40,6 +41,7 @@ const Button: React.FC<Props> = ({
loading = false,
destructive = false,
disabled = false,
active = false,
strokeWidth,
size = 'M',
spacing = 'S',
@ -68,10 +70,29 @@ const Button: React.FC<Props> = ({
)
const colorContent = useMemo(() => {
if (overlay) {
return theme.primaryOverlay
if (active) {
return theme.blue
} else {
if (disabled) {
if (overlay) {
return theme.primaryOverlay
} else {
if (disabled) {
return theme.secondary
} else {
if (destructive) {
return theme.red
} else {
return theme.primary
}
}
}
}
}, [theme, disabled])
const colorBorder = useMemo(() => {
if (active) {
return theme.blue
} else {
if (disabled || loading) {
return theme.secondary
} else {
if (destructive) {
@ -81,7 +102,14 @@ const Button: React.FC<Props> = ({
}
}
}
}, [theme, disabled])
}, [theme, loading, disabled])
const colorBackground = useMemo(() => {
if (overlay) {
return theme.backgroundOverlay
} else {
return theme.background
}
}, [theme])
const children = useMemo(() => {
switch (type) {
@ -118,26 +146,7 @@ const Button: React.FC<Props> = ({
</>
)
}
}, [theme, content, loading, disabled])
const colorBorder = useMemo(() => {
if (disabled || loading) {
return theme.secondary
} else {
if (destructive) {
return theme.red
} else {
return theme.primary
}
}
}, [theme, loading, disabled])
const colorBackground = useMemo(() => {
if (overlay) {
return theme.backgroundOverlay
} else {
return theme.background
}
}, [theme])
}, [theme, content, loading, disabled, active])
enum spacingMapping {
XS = 'S',
@ -164,7 +173,7 @@ const Button: React.FC<Props> = ({
testID='base'
onPress={onPress}
children={children}
disabled={disabled || loading}
disabled={disabled || active || loading}
/>
</Animated.View>
)

View File

@ -1,4 +1,3 @@
import { StyleConstants } from '@root/utils/styles/constants'
import React, { createElement } from 'react'
import { StyleProp, View, ViewStyle } from 'react-native'
import * as FeatherIcon from 'react-native-feather'

258
src/components/Instance.tsx Normal file
View File

@ -0,0 +1,258 @@
import Button from '@components/Button'
import haptics from '@components/haptics'
import Icon from '@components/Icon'
import { useNavigation } from '@react-navigation/native'
import hookApps from '@utils/queryHooks/apps'
import hookInstance from '@utils/queryHooks/instance'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { InstanceLocal, remoteUpdate } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { debounce } from 'lodash'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Image, StyleSheet, Text, TextInput, View } from 'react-native'
import { useQueryClient } from 'react-query'
import { useDispatch } from 'react-redux'
import InstanceAuth from './Instance/Auth'
import InstanceInfo from './Instance/Info'
import { toast } from './toast'
export interface Props {
type: 'local' | 'remote'
disableHeaderImage?: boolean
goBack?: boolean
}
const ComponentInstance: React.FC<Props> = ({
type,
disableHeaderImage,
goBack = false
}) => {
const navigation = useNavigation()
const dispatch = useDispatch()
const queryClient = useQueryClient()
const { t } = useTranslation('meRoot')
const { theme } = useTheme()
const [instanceDomain, setInstanceDomain] = useState<string | undefined>()
const [appData, setApplicationData] = useState<InstanceLocal['appData']>()
const instanceQuery = hookInstance({
instanceDomain,
options: { enabled: false, retry: false }
})
const applicationQuery = hookApps({
instanceDomain,
options: { enabled: false, retry: false }
})
useEffect(() => {
if (
applicationQuery.data?.client_id.length &&
applicationQuery.data?.client_secret.length
) {
setApplicationData({
clientId: applicationQuery.data.client_id,
clientSecret: applicationQuery.data.client_secret
})
}
}, [applicationQuery.data?.client_id])
const onChangeText = useCallback(
debounce(
text => {
setInstanceDomain(text.replace(/^http(s)?\:\/\//i, ''))
setApplicationData(undefined)
},
1000,
{
trailing: true
}
),
[]
)
useEffect(() => {
if (instanceDomain) {
instanceQuery.refetch()
}
}, [instanceDomain])
const processUpdate = useCallback(() => {
if (instanceDomain) {
haptics('Success')
switch (type) {
case 'local':
applicationQuery.refetch()
return
case 'remote':
const queryKey: QueryKeyTimeline = [
'Timeline',
{ page: 'RemotePublic' }
]
dispatch(remoteUpdate(instanceDomain))
queryClient.resetQueries(queryKey)
toast({ type: 'success', message: '重置成功' })
navigation.navigate('Screen-Remote-Root')
return
}
}
}, [instanceDomain])
const onSubmitEditing = useCallback(
({ nativeEvent: { text } }) => {
if (
text === instanceDomain &&
instanceQuery.isSuccess &&
instanceQuery.data &&
instanceQuery.data.uri
) {
processUpdate()
} else {
setInstanceDomain(text)
setApplicationData(undefined)
}
},
[instanceDomain, instanceQuery.isSuccess, instanceQuery.data]
)
const buttonContent = useMemo(() => {
switch (type) {
case 'local':
return t('content.login.button')
case 'remote':
return '登记'
}
}, [])
return (
<>
{!disableHeaderImage ? (
<View style={styles.imageContainer}>
<Image
source={require('assets/screens/meRoot/welcome.png')}
style={styles.image}
/>
</View>
) : null}
<View style={styles.base}>
<View style={styles.inputRow}>
<TextInput
style={[
styles.textInput,
{
color: theme.primary,
borderBottomColor: theme.border
}
]}
onChangeText={onChangeText}
autoCapitalize='none'
autoCorrect={false}
clearButtonMode='never'
keyboardType='url'
textContentType='URL'
onSubmitEditing={onSubmitEditing}
placeholder={t('content.login.server.placeholder')}
placeholderTextColor={theme.secondary}
returnKeyType='go'
/>
<Button
type='text'
content={buttonContent}
onPress={processUpdate}
disabled={!instanceQuery.data?.uri}
loading={instanceQuery.isFetching || applicationQuery.isFetching}
/>
</View>
<View>
<InstanceInfo
visible={instanceQuery.data?.title !== undefined}
header='实例名称'
content={instanceQuery.data?.title || undefined}
potentialWidth={10}
/>
<InstanceInfo
visible={instanceQuery.data?.short_description !== undefined}
header='实例介绍'
content={instanceQuery.data?.short_description || undefined}
potentialLines={5}
/>
<View style={styles.instanceStats}>
<InstanceInfo
style={{ alignItems: 'flex-start' }}
visible={instanceQuery.data?.stats?.user_count !== null}
header='用户总数'
content={
instanceQuery.data?.stats?.user_count?.toString() || undefined
}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'center' }}
visible={instanceQuery.data?.stats?.status_count !== null}
header='嘟嘟总数'
content={
instanceQuery.data?.stats?.status_count?.toString() || undefined
}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'flex-end' }}
visible={instanceQuery.data?.stats?.domain_count !== null}
header='嘟嘟总数'
content={
instanceQuery.data?.stats?.domain_count?.toString() || undefined
}
potentialWidth={4}
/>
</View>
<Text style={[styles.disclaimer, { color: theme.secondary }]}>
<Icon
name='Lock'
size={StyleConstants.Font.Size.M}
color={theme.secondary}
/>{' '}
</Text>
</View>
</View>
{type === 'local' && appData ? (
<InstanceAuth
instanceDomain={instanceDomain!}
appData={appData}
goBack={goBack}
/>
) : null}
</>
)
}
const styles = StyleSheet.create({
imageContainer: { flexDirection: 'row' },
image: { resizeMode: 'contain', flex: 1, aspectRatio: 16 / 9 },
base: {
marginVertical: StyleConstants.Spacing.L,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding
},
inputRow: {
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding
},
textInput: {
flex: 1,
borderBottomWidth: 1,
...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.M
},
instanceStats: {
flex: 1,
flexDirection: 'row'
},
disclaimer: {
...StyleConstants.FontStyle.S,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginVertical: StyleConstants.Spacing.M
}
})
export default ComponentInstance

View File

@ -0,0 +1,74 @@
import { useNavigation } from '@react-navigation/native'
import { InstanceLocal, localAddInstance } from '@utils/slices/instancesSlice'
import * as AuthSession from 'expo-auth-session'
import React, { useEffect } from 'react'
import { useQueryClient } from 'react-query'
import { useDispatch } from 'react-redux'
export interface Props {
instanceDomain: string
appData: InstanceLocal['appData']
goBack?: boolean
}
const InstanceAuth = React.memo(
({ instanceDomain, appData, goBack }: Props) => {
const navigation = useNavigation()
const queryClient = useQueryClient()
const dispatch = useDispatch()
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: appData.clientId,
clientSecret: appData.clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri: 'exp://127.0.0.1:19000'
},
{
authorizationEndpoint: `https://${instanceDomain}/oauth/authorize`
}
)
useEffect(() => {
;(async () => {
if (request?.clientId) {
await promptAsync()
}
})()
}, [request])
useEffect(() => {
;(async () => {
if (response?.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync(
{
clientId: appData.clientId,
clientSecret: appData.clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri: 'exp://127.0.0.1:19000',
code: response.params.code,
extraParams: {
grant_type: 'authorization_code'
}
},
{
tokenEndpoint: `https://${instanceDomain}/oauth/token`
}
)
queryClient.clear()
dispatch(
localAddInstance({
url: instanceDomain,
token: accessToken,
appData
})
)
goBack && navigation.goBack()
}
})()
}, [response])
return <></>
},
() => true
)
export default InstanceAuth

View File

@ -0,0 +1,73 @@
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React from 'react'
import { Dimensions, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'
export interface Props {
style?: ViewStyle
visible: boolean
header: string
content?: string
potentialWidth?: number
potentialLines?: number
}
const InstanceInfo = React.memo(
({
style,
visible,
header,
content,
potentialWidth,
potentialLines = 1
}: Props) => {
const { theme } = useTheme()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<View style={[styles.base, style]}>
<Text style={[styles.header, { color: theme.primary }]}>{header}</Text>
<ShimmerPlaceholder
visible={visible}
stopAutoRun
width={
potentialWidth
? potentialWidth * StyleConstants.Font.Size.M
: Dimensions.get('screen').width -
StyleConstants.Spacing.Global.PagePadding * 4
}
height={StyleConstants.Font.LineHeight.M * potentialLines}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
{content ? (
<ParseHTML
content={content}
size={'M'}
numberOfLines={5}
expandHint='介绍'
/>
) : null}
</ShimmerPlaceholder>
</View>
)
}
)
const styles = StyleSheet.create({
base: {
flex: 1,
marginTop: StyleConstants.Spacing.M,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding
},
header: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
}
})
export default InstanceInfo

View File

@ -134,7 +134,7 @@ const MenuRow: React.FC<Props> = ({
const styles = StyleSheet.create({
base: {
height: 50
minHeight: 50
},
core: {
flex: 1,

View File

@ -5,16 +5,12 @@ import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { useCallback, useMemo, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import React, { useCallback, useState } from 'react'
import { Image, Pressable, Text, View } from 'react-native'
import HTMLView from 'react-native-htmlview'
import Animated, {
measure,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
withTiming
} from 'react-native-reanimated'
@ -40,11 +36,62 @@ const renderNode = ({
showFullLink: boolean
disableDetails: boolean
}) => {
if (node.name == 'a') {
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
switch (node.name) {
case 'a':
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
return (
<Text
key={index}
style={{
color: theme.blue,
...StyleConstants.FontStyle[size]
}}
onPress={() => {
const tag = href.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
!disableDetails &&
navigation.push('Screen-Shared-Hashtag', {
hashtag: tag[1] || tag[2]
})
}}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
</Text>
)
} else if (classes.includes('mention') && mentions) {
const accountIndex = mentions.findIndex(
mention => mention.url === href
)
return (
<Text
key={index}
style={{
color: accountIndex !== -1 ? theme.blue : undefined,
...StyleConstants.FontStyle[size]
}}
onPress={() => {
accountIndex !== -1 &&
!disableDetails &&
navigation.push('Screen-Shared-Account', {
account: mentions[accountIndex]
})
}}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
</Text>
)
}
} else {
const domain = href.split(new RegExp(/:\/\/(.[^\/]+)/))
// Need example here
const content =
node.children && node.children[0] && node.children[0].data
const shouldBeTag =
tags && tags.filter(tag => `#${tag.name}` === content).length > 0
return (
<Text
key={index}
@ -52,78 +99,31 @@ const renderNode = ({
color: theme.blue,
...StyleConstants.FontStyle[size]
}}
onPress={() => {
const tag = href.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
!disableDetails &&
navigation.push('Screen-Shared-Hashtag', {
hashtag: tag[1] || tag[2]
})
}}
onPress={async () =>
!disableDetails && !shouldBeTag
? await openLink(href)
: navigation.push('Screen-Shared-Hashtag', {
hashtag: content.substring(1)
})
}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
</Text>
)
} else if (classes.includes('mention') && mentions) {
const accountIndex = mentions.findIndex(mention => mention.url === href)
return (
<Text
key={index}
style={{
color: accountIndex !== -1 ? theme.blue : undefined,
...StyleConstants.FontStyle[size]
}}
onPress={() => {
accountIndex !== -1 &&
!disableDetails &&
navigation.push('Screen-Shared-Account', {
account: mentions[accountIndex]
})
}}
>
{node.children[0].data}
{node.children[1]?.children[0].data}
{!shouldBeTag ? (
<Icon
color={theme.blue}
name='ExternalLink'
size={StyleConstants.Font.Size[size]}
/>
) : null}
{content || (showFullLink ? href : domain[1])}
</Text>
)
}
} else {
const domain = href.split(new RegExp(/:\/\/(.[^\/]+)/))
// Need example here
const content = node.children && node.children[0] && node.children[0].data
const shouldBeTag =
tags && tags.filter(tag => `#${tag.name}` === content).length > 0
return (
<Text
key={index}
style={{
color: theme.blue,
...StyleConstants.FontStyle[size]
}}
onPress={async () =>
!disableDetails && !shouldBeTag
? await openLink(href)
: navigation.push('Screen-Shared-Hashtag', {
hashtag: content.substring(1)
})
}
>
{!shouldBeTag ? (
<Icon
color={theme.blue}
name='ExternalLink'
size={StyleConstants.Font.Size[size]}
/>
) : null}
{content || (showFullLink ? href : domain[1])}
</Text>
)
}
} else {
if (node.name === 'p') {
break
case 'p':
if (!node.children.length) {
return <View key={index} /> // bug when the tag is empty
}
}
break
}
}

View File

@ -15,12 +15,12 @@ export interface Props {
const RelationshipIncoming: React.FC<Props> = ({ id }) => {
const { t } = useTranslation()
const relationshipQueryKey: QueryKey.Relationship = ['Relationship', { id }]
const relationshipQueryKey = ['Relationship', { id }]
const queryClient = useQueryClient()
const fireMutation = useCallback(
({ type }: { type: 'authorize' | 'reject' }) => {
return client({
return client<Mastodon.Relationship>({
method: 'post',
instance: 'local',
url: `follow_requests/${id}/${type}`
@ -29,9 +29,9 @@ const RelationshipIncoming: React.FC<Props> = ({ id }) => {
[]
)
const mutation = useMutation(fireMutation, {
onSuccess: ({ body }) => {
onSuccess: res => {
haptics('Success')
queryClient.setQueryData(relationshipQueryKey, body)
queryClient.setQueryData(relationshipQueryKey, res)
queryClient.refetchQueries(['Notifications'])
},
onError: (err: any, { type }) => {

View File

@ -2,10 +2,10 @@ import client from '@api/client'
import Button from '@components/Button'
import haptics from '@components/haptics'
import { toast } from '@components/toast'
import { relationshipFetch } from '@utils/fetches/relationshipFetch'
import hookRelationship from '@utils/queryHooks/relationship'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
id: Mastodon.Account['id']
@ -14,13 +14,13 @@ export interface Props {
const RelationshipOutgoing: React.FC<Props> = ({ id }) => {
const { t } = useTranslation()
const relationshipQueryKey: QueryKey.Relationship = ['Relationship', { id }]
const query = useQuery(relationshipQueryKey, relationshipFetch)
const relationshipQueryKey = ['Relationship', { id }]
const query = hookRelationship({ id })
const queryClient = useQueryClient()
const fireMutation = useCallback(
({ type, state }: { type: 'follow' | 'block'; state: boolean }) => {
return client({
return client<Mastodon.Relationship>({
method: 'post',
instance: 'local',
url: `accounts/${id}/${state ? 'un' : ''}${type}`
@ -29,9 +29,9 @@ const RelationshipOutgoing: React.FC<Props> = ({ id }) => {
[]
)
const mutation = useMutation(fireMutation, {
onSuccess: ({ body }) => {
onSuccess: res => {
haptics('Success')
queryClient.setQueryData(relationshipQueryKey, body)
queryClient.setQueryData(relationshipQueryKey, res)
},
onError: (err: any, { type }) => {
haptics('Error')

View File

@ -3,7 +3,7 @@ import Timeline from '@components/Timelines/Timeline'
import SegmentedControl from '@react-native-community/segmented-control'
import { useNavigation } from '@react-navigation/native'
import sharedScreens from '@screens/Shared/sharedScreens'
import { getLocalUrl, getRemoteUrl } from '@utils/slices/instancesSlice'
import { getLocalActiveIndex, getRemoteUrl } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useState } from 'react'
import { Dimensions, StyleSheet, View } from 'react-native'
@ -21,7 +21,7 @@ export interface Props {
const Timelines: React.FC<Props> = ({ name, content }) => {
const navigation = useNavigation()
const { mode } = useTheme()
const localRegistered = useSelector(getLocalUrl)
const localActiveIndex = useSelector(getLocalActiveIndex)
const publicDomain = useSelector(getRemoteUrl)
const [segment, setSegment] = useState(0)
@ -30,7 +30,7 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
}, [])
const routes = content
.filter(p => (localRegistered ? true : p.page === 'RemotePublic'))
.filter(p => (localActiveIndex !== null ? true : p.page === 'RemotePublic'))
.map(p => ({ key: p.page }))
const renderScene = useCallback(
@ -42,12 +42,12 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
}
}) => {
return (
(localRegistered || route.key === 'RemotePublic') && (
(localActiveIndex !== null || route.key === 'RemotePublic') && (
<Timeline page={route.key} />
)
)
},
[localRegistered]
[localActiveIndex]
)
const screenComponent = useCallback(
@ -62,7 +62,7 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
initialLayout={{ width: Dimensions.get('window').width }}
/>
),
[segment, localRegistered]
[segment, localActiveIndex]
)
return (
@ -71,7 +71,7 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
name={`Screen-${name}-Root`}
options={{
headerTitle: name === 'Public' ? publicDomain : '',
...(localRegistered && {
...(localActiveIndex !== null && {
headerCenter: () => (
<View style={styles.segmentsContainer}>
<SegmentedControl

View File

@ -3,16 +3,17 @@ import TimelineConversation from '@components/Timelines/Timeline/Conversation'
import TimelineDefault from '@components/Timelines/Timeline/Default'
import TimelineEmpty from '@components/Timelines/Timeline/Empty'
import TimelineEnd from '@root/components/Timelines/Timeline/End'
import TimelineHeader from '@components/Timelines/Timeline/Header'
import TimelineNotifications from '@components/Timelines/Timeline/Notifications'
import { useScrollToTop } from '@react-navigation/native'
import { timelineFetch } from '@utils/fetches/timelineFetch'
import { updateNotification } from '@utils/slices/instancesSlice'
import { localUpdateNotification } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { RefreshControl, StyleSheet } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { InfiniteData, useInfiniteQuery } from 'react-query'
import { InfiniteData } from 'react-query'
import { useDispatch } from 'react-redux'
import hookTimeline, { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export type TimelineData =
| InfiniteData<{
@ -41,15 +42,14 @@ const Timeline: React.FC<Props> = ({
disableRefresh = false,
disableInfinity = false
}) => {
const queryKey: QueryKey.Timeline = [
const queryKeyParams = {
page,
{
...(hashtag && { hashtag }),
...(list && { list }),
...(toot && { toot }),
...(account && { account })
}
]
...(hashtag && { hashtag }),
...(list && { list }),
...(toot && { toot }),
...(account && { account })
}
const queryKey: QueryKeyTimeline = ['Timeline', queryKeyParams]
const {
status,
data,
@ -61,24 +61,28 @@ const Timeline: React.FC<Props> = ({
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(queryKey, timelineFetch, {
getPreviousPageParam: firstPage => {
return firstPage.toots.length
? {
direction: 'prev',
id: firstPage.toots[0].id
}
: undefined
},
getNextPageParam: lastPage => {
return lastPage.toots.length
? {
direction: 'next',
id: lastPage.toots[lastPage.toots.length - 1].id
}
: undefined
} = hookTimeline({
...queryKeyParams,
options: {
getPreviousPageParam: firstPage => {
return firstPage.toots.length
? {
direction: 'prev',
id: firstPage.toots[0].id
}
: undefined
},
getNextPageParam: lastPage => {
return lastPage.toots.length
? {
direction: 'next',
id: lastPage.toots[lastPage.toots.length - 1].id
}
: undefined
}
}
})
const flattenData = data?.pages ? data.pages.flatMap(d => [...d?.toots]) : []
const flattenPointer = data?.pages
? data.pages.flatMap(d => [d?.pointer])
@ -92,9 +96,9 @@ const Timeline: React.FC<Props> = ({
useEffect(() => {
if (page === 'Notifications' && flattenData.length) {
dispatch(
updateNotification({
localUpdateNotification({
unread: false,
latestTime: flattenData[0].created_at
latestTime: (flattenData[0] as Mastodon.Notification).created_at
})
)
}
@ -130,7 +134,7 @@ const Timeline: React.FC<Props> = ({
item={item}
queryKey={queryKey}
index={index}
{...(queryKey[0] === 'RemotePublic' && {
{...(queryKey[1].page === 'RemotePublic' && {
disableDetails: true,
disableOnPress: true
})}
@ -166,6 +170,7 @@ const Timeline: React.FC<Props> = ({
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const ListHeaderComponent = useCallback(() => <TimelineHeader />, [])
const ListFooterComponent = useCallback(
() => <TimelineEnd hasNextPage={!disableInfinity ? hasNextPage : false} />,
[hasNextPage]
@ -207,6 +212,8 @@ const Timeline: React.FC<Props> = ({
ListEmptyComponent={flItemEmptyComponent}
{...(!disableRefresh && { refreshControl })}
ItemSeparatorComponent={ItemSeparatorComponent}
{...(queryKey &&
queryKey[1].page === 'RemotePublic' && { ListHeaderComponent })}
{...(toot && isSuccess && { onScrollToIndexFailed })}
maintainVisibleContentPosition={{
minIndexForVisible: 0,

View File

@ -10,27 +10,14 @@ import TimelineActions from '@components/Timelines/Timeline/Shared/Actions'
import client from '@root/api/client'
import { useMutation, useQueryClient } from 'react-query'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export interface Props {
conversation: Mastodon.Conversation
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
highlighted?: boolean
}
const fireMutation = async ({ id }: { id: Mastodon.Conversation['id'] }) => {
const res = await client({
method: 'post',
instance: 'local',
url: `conversations/${id}/read`
})
if (res.body.id === id) {
return Promise.resolve()
} else {
return Promise.reject()
}
}
const TimelineConversation: React.FC<Props> = ({
conversation,
queryKey,
@ -39,6 +26,13 @@ const TimelineConversation: React.FC<Props> = ({
const { theme } = useTheme()
const queryClient = useQueryClient()
const fireMutation = useCallback(() => {
return client<Mastodon.Conversation>({
method: 'post',
instance: 'local',
url: `conversations/${conversation.id}/read`
})
}, [])
const { mutate } = useMutation(fireMutation, {
onSettled: () => {
queryClient.invalidateQueries(queryKey)
@ -49,7 +43,7 @@ const TimelineConversation: React.FC<Props> = ({
const onPress = useCallback(() => {
if (conversation.last_status) {
conversation.unread && mutate({ id: conversation.id })
conversation.unread && mutate()
navigation.push('Screen-Shared-Toot', {
toot: conversation.last_status
})

View File

@ -7,7 +7,8 @@ import TimelineContent from '@components/Timelines/Timeline/Shared/Content'
import TimelineHeaderDefault from '@components/Timelines/Timeline/Shared/HeaderDefault'
import TimelinePoll from '@components/Timelines/Timeline/Shared/Poll'
import { useNavigation } from '@react-navigation/native'
import { getLocalAccountId } from '@utils/slices/instancesSlice'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback } from 'react'
import { Pressable, StyleSheet, View } from 'react-native'
@ -15,7 +16,7 @@ import { useSelector } from 'react-redux'
export interface Props {
item: Mastodon.Status
queryKey?: QueryKey.Timeline
queryKey?: QueryKeyTimeline
index: number
pinnedLength?: number
highlighted?: boolean
@ -33,7 +34,7 @@ const TimelineDefault: React.FC<Props> = ({
disableDetails = false,
disableOnPress = false
}) => {
const localAccountId = useSelector(getLocalAccountId)
const localAccount = useSelector(getLocalAccount)
const navigation = useNavigation()
let actualStatus = item.reblog ? item.reblog : item
@ -64,7 +65,7 @@ const TimelineDefault: React.FC<Props> = ({
<TimelineHeaderDefault
queryKey={disableOnPress ? undefined : queryKey}
status={actualStatus}
sameAccount={actualStatus.account.id === localAccountId}
sameAccount={actualStatus.account.id === localAccount?.id}
/>
</View>
@ -88,7 +89,7 @@ const TimelineDefault: React.FC<Props> = ({
queryKey={queryKey}
poll={actualStatus.poll}
reblog={item.reblog ? true : false}
sameAccount={actualStatus.account.id === localAccountId}
sameAccount={actualStatus.account.id === localAccount?.id}
/>
)}
{!disableDetails && actualStatus.media_attachments.length > 0 && (

View File

@ -0,0 +1,52 @@
import { useNavigation } from '@react-navigation/native'
import Icon from '@root/components/Icon'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
const TimelineHeader = React.memo(
() => {
const navigation = useNavigation()
const { theme } = useTheme()
return (
<View style={[styles.base, { borderColor: theme.border }]}>
<Text style={[styles.text, { color: theme.primary }]}>
{' '}
<Text
style={{ color: theme.blue }}
onPress={() =>
navigation.navigate('Screen-Me', {
screen: 'Screen-Me-Settings-UpdateRemote'
})
}
>
{' '}
<Icon
name='ArrowRight'
size={StyleConstants.Font.Size.S}
color={theme.blue}
/>
</Text>
</Text>
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {
margin: StyleConstants.Spacing.Global.PagePadding,
paddingHorizontal: StyleConstants.Spacing.M,
paddingVertical: StyleConstants.Spacing.S,
borderWidth: 1,
borderRadius: 6
},
text: {
...StyleConstants.FontStyle.S
}
})
export default TimelineHeader

View File

@ -7,7 +7,8 @@ import TimelineContent from '@components/Timelines/Timeline/Shared/Content'
import TimelineHeaderNotification from '@components/Timelines/Timeline/Shared/HeaderNotification'
import TimelinePoll from '@components/Timelines/Timeline/Shared/Poll'
import { useNavigation } from '@react-navigation/native'
import { getLocalAccountId } from '@utils/slices/instancesSlice'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback } from 'react'
import { Pressable, StyleSheet, View } from 'react-native'
@ -15,7 +16,7 @@ import { useSelector } from 'react-redux'
export interface Props {
notification: Mastodon.Notification
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
highlighted?: boolean
}
@ -24,7 +25,7 @@ const TimelineNotifications: React.FC<Props> = ({
queryKey,
highlighted = false
}) => {
const localAccountId = useSelector(getLocalAccountId)
const localAccount = useSelector(getLocalAccount)
const navigation = useNavigation()
const actualAccount = notification.status
? notification.status.account
@ -83,7 +84,7 @@ const TimelineNotifications: React.FC<Props> = ({
queryKey={queryKey}
poll={notification.status.poll}
reblog={false}
sameAccount={notification.account.id === localAccountId}
sameAccount={notification.account.id === localAccount?.id}
/>
)}
{notification.status.media_attachments.length > 0 && (

View File

@ -4,6 +4,7 @@ import Icon from '@components/Icon'
import { TimelineData } from '@components/Timelines/Timeline'
import { toast } from '@components/toast'
import { useNavigation } from '@react-navigation/native'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash'
@ -13,7 +14,7 @@ import { ActionSheetIOS, Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
status: Mastodon.Status
reblog: boolean
}
@ -36,7 +37,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
stateKey: 'favourited' | 'reblogged' | 'bookmarked'
state?: boolean
}) => {
return client({
return client<Mastodon.Status>({
method: 'post',
instance: 'local',
url: `statuses/${status.id}/${state ? 'un' : ''}${type}`
@ -58,7 +59,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
let tootIndex = -1
const pageIndex = findIndex(old?.pages, page => {
const tempIndex = findIndex(page.toots, [
queryKey[0] === 'Notifications'
queryKey[1].page === 'Notifications'
? 'status.id'
: reblog
? 'reblog.id'
@ -75,12 +76,12 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
if (pageIndex >= 0 && tootIndex >= 0) {
if (
(type === 'favourite' && queryKey[0] === 'Favourites') ||
(type === 'bookmark' && queryKey[0] === 'Bookmarks')
(type === 'favourite' && queryKey[1].page === 'Favourites') ||
(type === 'bookmark' && queryKey[1].page === 'Bookmarks')
) {
old!.pages[pageIndex].toots.splice(tootIndex, 1)
} else {
if (queryKey[0] === 'Notifications') {
if (queryKey[1].page === 'Notifications') {
old!.pages[pageIndex].toots[tootIndex].status[stateKey] =
typeof state === 'boolean' ? !state : true
} else {

View File

@ -3,9 +3,10 @@ import { Pressable, StyleSheet } from 'react-native'
import { Image } from 'react-native-expo-image-cache'
import { StyleConstants } from '@utils/styles/constants'
import { useNavigation } from '@react-navigation/native'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export interface Props {
queryKey?: QueryKey.Timeline
queryKey?: QueryKeyTimeline
account: Mastodon.Account
}

View File

@ -3,6 +3,7 @@ import haptics from '@components/haptics'
import Icon from '@components/Icon'
import { TimelineData } from '@components/Timelines/Timeline'
import { toast } from '@components/toast'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash'
@ -14,7 +15,7 @@ import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedCreated from './HeaderShared/Created'
export interface Props {
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
conversation: Mastodon.Conversation
}
@ -23,7 +24,7 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
const queryClient = useQueryClient()
const fireMutation = useCallback(() => {
return client({
return client<Mastodon.Conversation>({
method: 'delete',
instance: 'local',
url: `conversations/${conversation.id}`

View File

@ -13,9 +13,10 @@ import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedVisibility from './HeaderShared/Visibility'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export interface Props {
queryKey?: QueryKey.Timeline
queryKey?: QueryKeyTimeline
status: Mastodon.Status
sameAccount: boolean
}

View File

@ -2,12 +2,13 @@ import client from '@api/client'
import haptics from '@components/haptics'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { toast } from '@components/toast'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
queryKey?: QueryKey.Timeline
queryKey?: QueryKeyTimeline
account: Pick<Mastodon.Account, 'id' | 'acct'>
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
@ -25,14 +26,14 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
switch (type) {
case 'mute':
case 'block':
return client({
return client<Mastodon.Account>({
method: 'post',
instance: 'local',
url: `accounts/${account.id}/${type}`
})
break
case 'reports':
return client({
return client<Mastodon.Account>({
method: 'post',
instance: 'local',
url: `reports`,

View File

@ -3,12 +3,13 @@ import MenuContainer from '@components/Menu/Container'
import MenuHeader from '@components/Menu/Header'
import MenuRow from '@components/Menu/Row'
import { toast } from '@components/toast'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
domain: string
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
@ -21,7 +22,7 @@ const HeaderDefaultActionsDomain: React.FC<Props> = ({
const { t } = useTranslation()
const queryClient = useQueryClient()
const fireMutation = useCallback(() => {
return client({
return client<{}>({
method: 'post',
instance: 'local',
url: `domain_blocks`,

View File

@ -9,9 +9,10 @@ import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { TimelineData } from '@components/Timelines/Timeline'
import { toast } from '@components/toast'
import { useNavigation } from '@react-navigation/native'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export interface Props {
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
status: Mastodon.Status
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
@ -30,14 +31,14 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
switch (type) {
case 'mute':
case 'pin':
return client({
return client<Mastodon.Status>({
method: 'post',
instance: 'local',
url: `statuses/${status.id}/${state ? 'un' : ''}${type}`
}) // bug in response from Mastodon, but onMutate ignore the error in response
break
case 'delete':
return client({
return client<Mastodon.Status>({
method: 'delete',
instance: 'local',
url: `statuses/${status.id}`
@ -153,7 +154,7 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
),
style: 'destructive',
onPress: async () => {
await client({
await client<Mastodon.Status>({
method: 'delete',
instance: 'local',
url: `statuses/${status.id}`
@ -163,7 +164,7 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
setBottomSheetVisible(false)
navigation.navigate('Screen-Shared-Compose', {
type: 'edit',
incomingStatus: res.body
incomingStatus: res
})
})
.catch(() => {

View File

@ -6,6 +6,7 @@ import relativeTime from '@components/relativeTime'
import { TimelineData } from '@components/Timelines/Timeline'
import { ParseEmojis } from '@root/components/Parse'
import { toast } from '@root/components/toast'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash'
@ -15,7 +16,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKey.Timeline
queryKey: QueryKeyTimeline
poll: NonNullable<Mastodon.Status['poll']>
reblog: boolean
sameAccount: boolean
@ -45,7 +46,7 @@ const TimelinePoll: React.FC<Props> = ({
}
})
return client({
return client<Mastodon.Poll>({
method: type === 'vote' ? 'post' : 'get',
instance: 'local',
url: type === 'vote' ? `polls/${poll.id}/votes` : `polls/${poll.id}`,
@ -55,7 +56,7 @@ const TimelinePoll: React.FC<Props> = ({
[allOptions]
)
const mutation = useMutation(fireMutation, {
onSuccess: ({ body }) => {
onSuccess: (res) => {
queryClient.cancelQueries(queryKey)
queryClient.setQueryData<TimelineData>(queryKey, old => {
@ -75,9 +76,9 @@ const TimelinePoll: React.FC<Props> = ({
if (pageIndex >= 0 && tootIndex >= 0) {
if (reblog) {
old!.pages[pageIndex].toots[tootIndex].reblog!.poll = body
old!.pages[pageIndex].toots[tootIndex].reblog!.poll = res
} else {
old!.pages[pageIndex].toots[tootIndex].poll = body
old!.pages[pageIndex].toots[tootIndex].poll = res
}
}
return old

View File

@ -41,9 +41,7 @@ i18next.use(initReactI18next).init({
// react options
interpolation: {
escapeValue: false
},
debug: true
}
})
export default i18next

View File

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

View File

@ -26,8 +26,13 @@ export default {
cancel: '$t(common:buttons.cancel)'
}
},
remote: {
heading: '$t(meSettingsUpdateRemote:heading)',
description: '外站只能看不能玩'
},
cache: {
heading: '清空缓存'
heading: '清空缓存',
empty: '暂无缓存'
},
analytics: {
heading: '帮助我们改进',

View File

@ -0,0 +1,45 @@
export default {
heading: '外站链接',
content: {
language: {
heading: '切换语言',
options: {
zh: '简体中文',
en: 'English',
cancel: '$t(common:buttons.cancel)'
}
},
theme: {
heading: '应用外观',
options: {
auto: '跟随系统',
light: '浅色模式',
dark: '深色模式',
cancel: '$t(common:buttons.cancel)'
}
},
browser: {
heading: '打开链接',
options: {
internal: '应用内',
external: '系统浏览器',
cancel: '$t(common:buttons.cancel)'
}
},
remote: {
heading: '外站链接',
description: '外站只能看不能玩'
},
cache: {
heading: '清空缓存'
},
analytics: {
heading: '帮助我们改进',
description: '允许我们收集不与用户相关联的使用信息'
},
copyrights: {
heading: '版权信息'
},
version: '版本 v{{version}}'
}
}

View File

@ -12,6 +12,9 @@ import ScreenMeListsList from '@screens/Me/Root/Lists/List'
import ScreenMeSettings from '@screens/Me/Settings'
import { HeaderLeft } from '@root/components/Header'
import UpdateRemote from './Me/Settings/UpdateRemote'
import { CommonActions } from '@react-navigation/native'
import ScreenMeSwitch from './Me/Switch'
const Stack = createNativeStackNavigator()
@ -34,7 +37,7 @@ const ScreenMe: React.FC = () => {
component={ScreenMeConversations}
options={({ navigation }: any) => ({
headerTitle: t('meConversations:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
@ -42,7 +45,7 @@ const ScreenMe: React.FC = () => {
component={ScreenMeBookmarks}
options={({ navigation }: any) => ({
headerTitle: t('meBookmarks:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
@ -50,7 +53,7 @@ const ScreenMe: React.FC = () => {
component={ScreenMeFavourites}
options={({ navigation }: any) => ({
headerTitle: t('meFavourites:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
@ -58,7 +61,7 @@ const ScreenMe: React.FC = () => {
component={ScreenMeLists}
options={({ navigation }: any) => ({
headerTitle: t('meLists:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
@ -66,7 +69,7 @@ const ScreenMe: React.FC = () => {
component={ScreenMeListsList}
options={({ route, navigation }: any) => ({
headerTitle: t('meListsList:heading', { list: route.params.title }),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
@ -74,7 +77,24 @@ const ScreenMe: React.FC = () => {
component={ScreenMeSettings}
options={({ navigation }: any) => ({
headerTitle: t('meSettings:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Screen-Me-Settings-UpdateRemote'
component={UpdateRemote}
options={({ navigation }: any) => ({
headerTitle: t('meSettingsUpdateRemote:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>
<Stack.Screen
name='Screen-Me-Switch'
component={ScreenMeSwitch}
options={({ navigation }: any) => ({
stackPresentation: 'fullScreenModal',
headerTitle: t('meSettings:heading'),
headerLeft: () => <HeaderLeft onPress={() => navigation.pop(1)} />
})}
/>

View File

@ -1,14 +1,13 @@
import { MenuRow } from '@components/Menu'
import { useNavigation } from '@react-navigation/native'
import TimelineEmpty from '@root/components/Timelines/Timeline/Empty'
import { listsFetch } from '@utils/fetches/listsFetch'
import hookLists from '@utils/queryHooks/lists'
import React, { useMemo } from 'react'
import { StyleSheet } from 'react-native'
import { useQuery } from 'react-query'
const ScreenMeLists: React.FC = () => {
const navigation = useNavigation()
const { status, data, refetch } = useQuery(['Lists'], listsFetch)
const { status, data, refetch } = hookLists({})
const children = useMemo(() => {
if (status === 'success') {

View File

@ -1,6 +1,5 @@
import { useScrollToTop } from '@react-navigation/native'
import Collections from '@screens/Me/Root/Collections'
import Login from '@screens/Me/Root/Login'
import MyInfo from '@screens/Me/Root/MyInfo'
import Settings from '@screens/Me/Root/Settings'
import Logout from '@screens/Me/Root/Logout'
@ -8,16 +7,17 @@ import AccountNav from '@screens/Shared/Account/Nav'
import accountReducer from '@screens/Shared/Account/utils/reducer'
import accountInitialState from '@screens/Shared/Account/utils/initialState'
import AccountContext from '@screens/Shared/Account/utils/createContext'
import { getLocalUrl } from '@utils/slices/instancesSlice'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import React, { useReducer, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import Animated, {
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import ComponentInstance from '@components/Instance'
const ScreenMeRoot: React.FC = () => {
const localRegistered = useSelector(getLocalUrl)
const localActiveIndex = useSelector(getLocalActiveIndex)
const scrollRef = useRef<Animated.ScrollView>(null)
useScrollToTop(scrollRef)
@ -36,7 +36,7 @@ const ScreenMeRoot: React.FC = () => {
return (
<AccountContext.Provider value={{ accountState, accountDispatch }}>
{localRegistered && data ? (
{localActiveIndex !== null && data ? (
<AccountNav scrollY={scrollY} account={data} />
) : null}
<Animated.ScrollView
@ -45,10 +45,14 @@ const ScreenMeRoot: React.FC = () => {
onScroll={onScroll}
scrollEventThrottle={16}
>
{localRegistered ? <MyInfo setData={setData} /> : <Login />}
{localRegistered && <Collections />}
{localActiveIndex !== null ? (
<MyInfo setData={setData} />
) : (
<ComponentInstance type='local' />
)}
{localActiveIndex !== null ? <Collections /> : null}
<Settings />
{localRegistered && <Logout />}
{localActiveIndex !== null ? <Logout /> : null}
</Animated.ScrollView>
</AccountContext.Provider>
)

View File

@ -3,15 +3,13 @@ import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { MenuContainer, MenuRow } from '@components/Menu'
import { useQuery } from 'react-query'
import { announcementFetch } from '@root/utils/fetches/announcementsFetch'
import hookAnnouncement from '@utils/queryHooks/announcement'
const Collections: React.FC = () => {
const { t } = useTranslation('meRoot')
const navigation = useNavigation()
const queryKey = ['Announcements', { showAll: true }]
const { data, isFetching } = useQuery(queryKey, announcementFetch)
const { data, isFetching } = hookAnnouncement({ showAll: true })
const announcementContent = useMemo(() => {
if (data) {

View File

@ -1,345 +0,0 @@
import analytics from '@components/analytics'
import Button from '@components/Button'
import haptics from '@components/haptics'
import Icon from '@components/Icon'
import { ParseHTML } from '@components/Parse'
import { useNavigation } from '@react-navigation/native'
import { applicationFetch } from '@utils/fetches/applicationFetch'
import { instanceFetch } from '@utils/fetches/instanceFetch'
import { loginLocal } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as AuthSession from 'expo-auth-session'
import { LinearGradient } from 'expo-linear-gradient'
import { debounce } from 'lodash'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dimensions,
Image,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'
import { useQuery } from 'react-query'
import { useDispatch } from 'react-redux'
const Login: React.FC = () => {
const { t } = useTranslation('meRoot')
const { theme } = useTheme()
const navigation = useNavigation()
const dispatch = useDispatch()
const [instanceDomain, setInstanceDomain] = useState<string | undefined>()
const [applicationData, setApplicationData] = useState<{
clientId: string
clientSecret: string
}>()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const instanceQuery = useQuery(
['Instance', { instanceDomain }],
instanceFetch,
{ enabled: false, retry: false }
)
const applicationQuery = useQuery(
['Application', { instanceDomain }],
applicationFetch,
{ enabled: false, retry: false }
)
useEffect(() => {
if (
applicationQuery.data?.client_id.length &&
applicationQuery.data?.client_secret.length
) {
setApplicationData({
clientId: applicationQuery.data.client_id,
clientSecret: applicationQuery.data.client_secret
})
}
}, [applicationQuery.data?.client_id])
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: applicationData?.clientId!,
clientSecret: applicationData?.clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri: 'exp://127.0.0.1:19000'
},
{
authorizationEndpoint: `https://${instanceDomain}/oauth/authorize`
}
)
useEffect(() => {
;(async () => {
if (request?.clientId) {
await promptAsync()
}
})()
}, [request])
useEffect(() => {
;(async () => {
if (response?.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync(
{
clientId: applicationData?.clientId!,
clientSecret: applicationData?.clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri: 'exp://127.0.0.1:19000',
code: response.params.code,
extraParams: {
grant_type: 'authorization_code'
}
},
{
tokenEndpoint: `https://${instanceDomain}/oauth/token`
}
)
dispatch(loginLocal({ url: instanceDomain, token: accessToken }))
analytics('login', {
instance: instanceDomain!,
method: 'OAuth2'
})
navigation.navigate('Screen-Local')
}
})()
}, [response])
const onChangeText = useCallback(
debounce(
text => {
setInstanceDomain(text.replace(/^http(s)?\:\/\//i, ''))
setApplicationData(undefined)
},
1000,
{
trailing: true
}
),
[]
)
useEffect(() => {
if (instanceDomain) {
instanceQuery.refetch()
}
}, [instanceDomain])
const instanceInfo = useCallback(
({ header, content }: { header: string; content?: string }) => {
return (
<View style={styles.instanceInfo}>
<Text style={[styles.instanceInfoHeader, { color: theme.primary }]}>
{header}
</Text>
<ShimmerPlaceholder
visible={instanceQuery.data?.uri !== undefined}
stopAutoRun
width={
Dimensions.get('screen').width -
StyleConstants.Spacing.Global.PagePadding * 4
}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<ParseHTML
content={content!}
size={'M'}
numberOfLines={5}
expandHint='介绍'
/>
</ShimmerPlaceholder>
</View>
)
},
[instanceQuery.data?.uri]
)
return (
<>
<View style={{ flexDirection: 'row' }}>
<Image
source={require('assets/screens/meRoot/welcome.png')}
style={{ resizeMode: 'contain', flex: 1, aspectRatio: 16 / 9 }}
/>
</View>
<View style={styles.base}>
<View style={styles.inputRow}>
<TextInput
style={[
styles.textInput,
{
color: theme.primary,
borderBottomColor: theme.secondary
}
]}
onChangeText={onChangeText}
autoCapitalize='none'
autoCorrect={false}
clearButtonMode='never'
keyboardType='url'
textContentType='URL'
onSubmitEditing={({ nativeEvent: { text } }) => {
if (
text === instanceDomain &&
instanceQuery.isSuccess &&
instanceQuery.data &&
instanceQuery.data.uri
) {
haptics('Success')
applicationQuery.refetch()
} else {
setInstanceDomain(text)
setApplicationData(undefined)
}
}}
placeholder={t('content.login.server.placeholder')}
placeholderTextColor={theme.secondary}
returnKeyType='go'
/>
<Button
type='text'
content={t('content.login.button')}
onPress={() => {
haptics('Success')
applicationQuery.refetch()
}}
disabled={!instanceQuery.data?.uri}
loading={instanceQuery.isFetching || applicationQuery.isFetching}
/>
</View>
<View>
{instanceInfo({
header: '实例名称',
content: instanceQuery.data?.title
})}
{instanceInfo({
header: '实例介绍',
content: instanceQuery.data?.short_description
})}
<View style={styles.instanceStats}>
<View style={styles.instanceStat}>
<Text
style={[styles.instanceInfoHeader, { color: theme.primary }]}
>
</Text>
<ShimmerPlaceholder
visible={instanceQuery.data?.stats?.user_count !== undefined}
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}
>
{instanceQuery.data?.stats?.user_count}
</Text>
</ShimmerPlaceholder>
</View>
<View style={[styles.instanceStat, { alignItems: 'center' }]}>
<Text
style={[styles.instanceInfoHeader, { color: theme.primary }]}
>
</Text>
<ShimmerPlaceholder
visible={instanceQuery.data?.stats?.user_count !== undefined}
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}
>
{instanceQuery.data?.stats?.status_count}
</Text>
</ShimmerPlaceholder>
</View>
<View style={[styles.instanceStat, { alignItems: 'flex-end' }]}>
<Text
style={[styles.instanceInfoHeader, { color: theme.primary }]}
>
</Text>
<ShimmerPlaceholder
visible={instanceQuery.data?.stats?.user_count !== undefined}
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}
>
{instanceQuery.data?.stats?.domain_count}
</Text>
</ShimmerPlaceholder>
</View>
</View>
<Text style={[styles.disclaimer, { color: theme.secondary }]}>
<Icon
name='Lock'
size={StyleConstants.Font.Size.M}
color={theme.secondary}
/>{' '}
</Text>
</View>
</View>
</>
)
}
const styles = StyleSheet.create({
base: {
padding: StyleConstants.Spacing.Global.PagePadding
},
inputRow: {
flexDirection: 'row'
},
textInput: {
flex: 1,
borderBottomWidth: 1,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.S
},
instanceInfo: {
marginTop: StyleConstants.Spacing.M,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding
},
instanceInfoHeader: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
},
instanceInfoContent: { ...StyleConstants.FontStyle.M },
instanceStats: {
flex: 1,
flexDirection: 'row',
marginTop: StyleConstants.Spacing.M,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.M
},
instanceStat: {
flex: 1
},
disclaimer: {
...StyleConstants.FontStyle.S,
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.M
}
})
Login.whyDidYouRender = true
export default Login

View File

@ -1,8 +1,6 @@
import Button from '@components/Button'
import { useNavigation } from '@react-navigation/native'
import analytics from '@root/components/analytics'
import haptics from '@root/components/haptics'
import { resetLocal } from '@utils/slices/instancesSlice'
import { localRemoveInstance } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { useTranslation } from 'react-i18next'
@ -13,7 +11,6 @@ import { useDispatch } from 'react-redux'
const Logout: React.FC = () => {
const { t } = useTranslation('meRoot')
const dispatch = useDispatch()
const navigation = useNavigation()
const queryClient = useQueryClient()
return (
@ -36,12 +33,7 @@ const Logout: React.FC = () => {
onPress: () => {
haptics('Success')
queryClient.clear()
dispatch(resetLocal())
analytics('logout')
navigation.navigate('Screen-Public', {
screen: 'Screen-Public-Root',
params: { publicTab: true }
})
dispatch(localRemoveInstance())
}
},
{

View File

@ -1,9 +1,8 @@
import AccountHeader from '@screens/Shared/Account/Header'
import AccountInformation from '@screens/Shared/Account/Information'
import { accountFetch } from '@utils/fetches/accountFetch'
import { getLocalAccountId } from '@utils/slices/instancesSlice'
import hookAccount from '@utils/queryHooks/account'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import React, { useEffect } from 'react'
import { useQuery } from 'react-query'
import { useSelector } from 'react-redux'
export interface Props {
@ -11,8 +10,9 @@ export interface Props {
}
const MyInfo: React.FC<Props> = ({ setData }) => {
const localAccountId = useSelector(getLocalAccountId)
const { data } = useQuery(['Account', { id: localAccountId }], accountFetch)
const localAccount = useSelector(getLocalAccount)
const { data } = hookAccount({ id: localAccount!.id })
useEffect(() => {
if (data) {
setData(data)
@ -22,7 +22,7 @@ const MyInfo: React.FC<Props> = ({ setData }) => {
return (
<>
<AccountHeader account={data} limitHeight />
<AccountInformation account={data} disableActions />
<AccountInformation account={data} ownAccount />
</>
)
}

View File

@ -1,5 +1,13 @@
import Button from '@components/Button'
import { MenuContainer, MenuRow } from '@components/Menu'
import { useNavigation } from '@react-navigation/native'
import haptics from '@root/components/haptics'
import { persistor } from '@root/store'
import {
getLocalActiveIndex,
getLocalInstances,
getRemoteUrl
} from '@root/utils/slices/instancesSlice'
import {
changeAnalytics,
changeBrowser,
@ -13,18 +21,63 @@ import {
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import prettyBytes from 'pretty-bytes'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionSheetIOS, Button, StyleSheet, Text, View } from 'react-native'
import { ActionSheetIOS, StyleSheet, Text } from 'react-native'
import { CacheManager } from 'react-native-expo-image-cache'
import { useDispatch, useSelector } from 'react-redux'
const DevDebug: React.FC = () => {
const localActiveIndex = useSelector(getLocalActiveIndex)
const localInstances = useSelector(getLocalInstances)
return (
<MenuContainer>
<MenuRow
title={'Local active index'}
content={typeof localActiveIndex + ' - ' + localActiveIndex}
onPress={() => {}}
/>
<MenuRow
title={'Saved local instances'}
content={localInstances.length.toString()}
iconBack='ChevronRight'
onPress={() =>
ActionSheetIOS.showActionSheetWithOptions(
{
options: localInstances
.map(instance => {
return instance.url + ': ' + instance.account.id
})
.concat(['Cancel']),
cancelButtonIndex: localInstances.length
},
buttonIndex => {}
)
}
/>
<Button
type='text'
content={'Purge secure storage'}
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding * 2
}}
destructive
onPress={() => persistor.purge()}
/>
</MenuContainer>
)
}
const ScreenMeSettings: React.FC = () => {
const navigation = useNavigation()
const { t, i18n } = useTranslation('meSettings')
const { setTheme, theme } = useTheme()
const settingsLanguage = useSelector(getSettingsLanguage)
const settingsTheme = useSelector(getSettingsTheme)
const settingsBrowser = useSelector(getSettingsBrowser)
const settingsRemote = useSelector(getRemoteUrl)
const settingsAnalytics = useSelector(getSettingsAnalytics)
const dispatch = useDispatch()
@ -134,9 +187,18 @@ const ScreenMeSettings: React.FC = () => {
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('content.remote.heading')}
description={t('content.remote.description')}
content={settingsRemote}
iconBack='ChevronRight'
onPress={() => navigation.navigate('Screen-Me-Settings-UpdateRemote')}
/>
<MenuRow
title={t('content.cache.heading')}
content={cacheSize ? prettyBytes(cacheSize) : '暂无缓存'}
content={
cacheSize ? prettyBytes(cacheSize) : t('content.cache.empty')
}
iconBack='ChevronRight'
onPress={async () => {
await CacheManager.clearCache()
@ -144,6 +206,8 @@ const ScreenMeSettings: React.FC = () => {
setCacheSize(0)
}}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('content.analytics.heading')}
description={t('content.analytics.description')}
@ -160,6 +224,8 @@ const ScreenMeSettings: React.FC = () => {
{t('content.version', { version: '1.0.0' })}
</Text>
</MenuContainer>
{__DEV__ ? <DevDebug /> : null}
</>
)
}

View File

@ -0,0 +1,16 @@
import ComponentInstance from '@components/Instance'
import React from 'react'
import { KeyboardAvoidingView } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
const UpdateRemote: React.FC = () => {
return (
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
<ScrollView keyboardShouldPersistTaps='handled'>
<ComponentInstance type='remote' disableHeaderImage />
</ScrollView>
</KeyboardAvoidingView>
)
}
export default UpdateRemote

28
src/screens/Me/Switch.tsx Normal file
View File

@ -0,0 +1,28 @@
import { HeaderLeft, HeaderRight } from '@components/Header'
import React from 'react'
import { StyleSheet } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import ScreenMeSwitchRoot from './Switch/Root'
const Stack = createNativeStackNavigator()
const ScreenMeSwitch: React.FC = ({ navigation }) => {
return (
<Stack.Navigator screenOptions={{ headerHideShadow: true }}>
<Stack.Screen
name='Screen-Me-Switch-Root'
component={ScreenMeSwitchRoot}
options={{
headerTitle: '切换账号',
headerLeft: () => (
<HeaderLeft content='X' onPress={() => navigation.goBack()} />
)
}}
/>
</Stack.Navigator>
)
}
const styles = StyleSheet.create({})
export default ScreenMeSwitch

View File

@ -0,0 +1,117 @@
import Button from '@components/Button'
import ComponentInstance from '@components/Instance'
import { useNavigation } from '@react-navigation/native'
import hookAccountCheck from '@utils/queryHooks/accountCheck'
import {
getLocalActiveIndex,
getLocalInstances,
InstanceLocal,
InstancesState,
localUpdateActiveIndex
} from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { KeyboardAvoidingView, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useQueryClient } from 'react-query'
import { useDispatch, useSelector } from 'react-redux'
interface Props {
index: NonNullable<InstancesState['local']['activeIndex']>
instance: InstanceLocal
active?: boolean
}
const AccountButton: React.FC<Props> = ({
index,
instance,
active = false
}) => {
const queryClient = useQueryClient()
const navigation = useNavigation()
const dispatch = useDispatch()
const { isLoading, data } = hookAccountCheck({
id: instance.account.id,
index,
options: { retry: false }
})
return (
<Button
type='text'
active={active}
loading={isLoading}
style={styles.button}
content={`@${data?.acct || '...'}@${instance.url}`}
onPress={() => {
dispatch(localUpdateActiveIndex(index))
queryClient.clear()
navigation.goBack()
}}
/>
)
}
const ScreenMeSwitchRoot = () => {
const { theme } = useTheme()
const localInstances = useSelector(getLocalInstances)
const localActiveIndex = useSelector(getLocalActiveIndex)
return (
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
<ScrollView keyboardShouldPersistTaps='handled'>
<View style={styles.firstSection}>
<Text style={[styles.header, { color: theme.primary }]}>
</Text>
<ComponentInstance type='local' disableHeaderImage goBack />
</View>
<View style={[styles.secondSection, { borderTopColor: theme.border }]}>
<Text style={[styles.header, { color: theme.primary }]}>
</Text>
<View style={styles.accountButtons}>
{localInstances.length
? localInstances.map((instance, index) => (
<AccountButton
key={index}
index={index}
instance={instance}
active={localActiveIndex === index}
/>
))
: null}
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
)
}
const styles = StyleSheet.create({
header: {
...StyleConstants.FontStyle.M,
textAlign: 'center',
paddingVertical: StyleConstants.Spacing.S
},
firstSection: {
marginTop: StyleConstants.Spacing.S
},
secondSection: {
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
paddingTop: StyleConstants.Spacing.M,
borderTopWidth: StyleSheet.hairlineWidth
},
accountButtons: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: StyleConstants.Spacing.M
},
button: {
marginBottom: StyleConstants.Spacing.M
}
})
export default ScreenMeSwitchRoot

View File

@ -1,19 +1,16 @@
import React from 'react'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Timeline from '@components/Timelines/Timeline'
import sharedScreens from '@screens/Shared/sharedScreens'
import { useSelector } from 'react-redux'
import { RootState } from '@root/store'
import { getLocalActiveIndex } from '@utils/slices/instancesSlice'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { useSelector } from 'react-redux'
const Stack = createNativeStackNavigator()
const ScreenNotifications: React.FC = () => {
const { t } = useTranslation()
const localRegistered = useSelector(
(state: RootState) => state.instances.local.url
)
const localActiveIndex = useSelector(getLocalActiveIndex)
return (
<Stack.Navigator
@ -23,7 +20,9 @@ const ScreenNotifications: React.FC = () => {
}}
>
<Stack.Screen name='Screen-Notifications-Root'>
{() => (localRegistered ? <Timeline page='Notifications' /> : null)}
{() =>
localActiveIndex !== null ? <Timeline page='Notifications' /> : null
}
</Stack.Screen>
{sharedScreens(Stack)}

View File

@ -1,14 +1,13 @@
import BottomSheet from '@components/BottomSheet'
import { HeaderRight } from '@components/Header'
import HeaderDefaultActionsAccount from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsAccount'
import { accountFetch } from '@utils/fetches/accountFetch'
import { getLocalAccountId } from '@utils/slices/instancesSlice'
import hookAccount from '@utils/queryHooks/account'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import React, { useEffect, useReducer, useRef, useState } from 'react'
import Animated, {
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useQuery } from 'react-query'
import { useSelector } from 'react-redux'
import AccountHeader from './Account/Header'
import AccountInformation from './Account/Information'
@ -36,8 +35,8 @@ const ScreenSharedAccount: React.FC<Props> = ({
},
navigation
}) => {
const localAccountId = useSelector(getLocalAccountId)
const { data } = useQuery(['Account', { id: account.id }], accountFetch)
const localAccount = useSelector(getLocalAccount)
const { data } = hookAccount({ id: account.id })
const scrollY = useSharedValue(0)
const [accountState, accountDispatch] = useReducer(
@ -85,7 +84,7 @@ const ScreenSharedAccount: React.FC<Props> = ({
handleDismiss={() => setBottomSheetVisible(false)}
>
{/* 添加到列表 */}
{localAccountId !== account.id && (
{localAccount?.id !== account.id && (
<HeaderDefaultActionsAccount
account={account}
setBottomSheetVisible={setBottomSheetVisible}

View File

@ -1,24 +1,25 @@
import { StyleConstants } from '@utils/styles/constants'
import React, { createRef, useCallback, useContext, useEffect } from 'react'
import { Animated, StyleSheet, View } from 'react-native'
import AccountInformationAvatar from './Information/Avatar'
import AccountInformationName from './Information/Name'
import AccountInformationAccount from './Information/Account'
import AccountInformationCreated from './Information/Created'
import AccountInformationStats from './Information/Stats'
import AccountInformationActions from './Information/Actions'
import AccountInformationAvatar from './Information/Avatar'
import AccountInformationCreated from './Information/Created'
import AccountInformationFields from './Information/Fields'
import AccountInformationName from './Information/Name'
import AccountInformationNotes from './Information/Notes'
import AccountInformationStats from './Information/Stats'
import AccountInformationSwitch from './Information/Switch'
import AccountContext from './utils/createContext'
export interface Props {
account: Mastodon.Account | undefined
disableActions?: boolean
ownAccount?: boolean
}
const AccountInformation: React.FC<Props> = ({
account,
disableActions = false
ownAccount = false
}) => {
const { accountDispatch } = useContext(AccountContext)
const shimmerAvatarRef = createRef<any>()
@ -59,9 +60,13 @@ const AccountInformation: React.FC<Props> = ({
{/* <Text>Moved or not: {account.moved}</Text> */}
<View style={styles.avatarAndActions}>
<AccountInformationAvatar ref={shimmerAvatarRef} account={account} />
{!disableActions ? (
<AccountInformationActions account={account} />
) : null}
<View style={styles.actions}>
{ownAccount ? (
<AccountInformationSwitch />
) : (
<AccountInformationActions account={account} />
)}
</View>
</View>
<AccountInformationName ref={shimmerNameRef} account={account} />
@ -94,6 +99,10 @@ const styles = StyleSheet.create({
avatarAndActions: {
flexDirection: 'row',
justifyContent: 'space-between'
},
actions: {
alignSelf: 'flex-end',
flexDirection: 'row'
}
})

View File

@ -25,7 +25,7 @@ const AccountInformationAccount = forwardRef<ShimmerPlaceholder, Props>(
width={StyleConstants.Font.Size.M * 8}
height={StyleConstants.Font.Size.M}
style={{ marginBottom: StyleConstants.Spacing.L }}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<View style={styles.account}>
<Text

View File

@ -1,47 +1,45 @@
import Button from '@components/Button'
import { RelationshipOutgoing } from '@components/Relationship'
import { useNavigation } from '@react-navigation/native'
import { relationshipFetch } from '@utils/fetches/relationshipFetch'
import hookRelationship from '@utils/queryHooks/relationship'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { StyleSheet, View } from 'react-native'
import { useQuery } from 'react-query'
import { StyleSheet } from 'react-native'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationActions: React.FC<Props> = ({ account }) => {
const Conversation = ({ account }: { account: Mastodon.Account }) => {
const navigation = useNavigation()
const relationshipQueryKey = ['Relationship', { id: account?.id }]
const query = useQuery(relationshipQueryKey, relationshipFetch)
const query = hookRelationship({ id: account.id })
return (
<View style={styles.actions}>
{query.data && !query.data.blocked_by ? (
<Button
round
type='icon'
content='Mail'
style={styles.actionConversation}
onPress={() =>
navigation.navigate('Screen-Shared-Compose', {
type: 'conversation',
incomingStatus: { account }
})
}
/>
) : null}
{account && account.id && <RelationshipOutgoing id={account.id} />}
</View>
)
return query.data && !query.data.blocked_by ? (
<Button
round
type='icon'
content='Mail'
style={styles.actionConversation}
onPress={() =>
navigation.navigate('Screen-Shared-Compose', {
type: 'conversation',
incomingStatus: { account }
})
}
/>
) : null
}
const AccountInformationActions: React.FC<Props> = ({ account }) => {
return account && account.id ? (
<>
<Conversation account={account} />
<RelationshipOutgoing id={account.id} />
</>
) : null
}
const styles = StyleSheet.create({
actions: {
alignSelf: 'flex-end',
flexDirection: 'row'
},
actionConversation: { marginRight: StyleConstants.Spacing.S }
})

View File

@ -24,7 +24,7 @@ const AccountInformationAvatar = forwardRef<ShimmerPlaceholder, Props>(
visible={avatarLoaded}
width={StyleConstants.Avatar.L}
height={StyleConstants.Avatar.L}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<Image
source={{ uri: account?.avatar }}

View File

@ -26,7 +26,7 @@ const AccountInformationCreated = forwardRef<ShimmerPlaceholder, Props>(
width={StyleConstants.Font.Size.S * 8}
height={StyleConstants.Font.Size.S}
style={{ marginBottom: StyleConstants.Spacing.M }}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<View style={styles.created}>
<Icon

View File

@ -25,6 +25,7 @@ const AccountInformationFields: React.FC<Props> = ({ account }) => {
size={'M'}
emojis={account.emojis}
showFullLink
numberOfLines={3}
/>
{field.verified_at ? (
<Icon
@ -41,6 +42,7 @@ const AccountInformationFields: React.FC<Props> = ({ account }) => {
size={'M'}
emojis={account.emojis}
showFullLink
numberOfLines={3}
/>
</View>
</View>

View File

@ -26,7 +26,7 @@ const AccountInformationName = forwardRef<ShimmerPlaceholder, Props>(
width={StyleConstants.Font.Size.L * 8}
height={StyleConstants.Font.Size.L}
style={styles.name}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
{account ? (
<ParseEmojis

View File

@ -41,7 +41,7 @@ const AccountInformationStats = forwardRef<any, Props>(({ account }, ref) => {
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<Text style={[styles.stat, { color: theme.primary }]}>
{t('content.summary.statuses_count', {
@ -54,7 +54,7 @@ const AccountInformationStats = forwardRef<any, Props>(({ account }, ref) => {
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'right' }]}
@ -76,7 +76,7 @@ const AccountInformationStats = forwardRef<any, Props>(({ account }, ref) => {
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'center' }]}

View File

@ -0,0 +1,17 @@
import Button from '@components/Button'
import { useNavigation } from '@react-navigation/native'
import React from 'react'
const AccountInformationSwitch: React.FC = () => {
const navigation = useNavigation()
return (
<Button
type='text'
content='切换账号'
onPress={() => navigation.navigate('Screen-Me-Switch')}
/>
)
}
export default AccountInformationSwitch

View File

@ -4,7 +4,7 @@ import haptics from '@components/haptics'
import { ParseHTML } from '@components/Parse'
import relativeTime from '@components/relativeTime'
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'
import { announcementFetch } from '@utils/fetches/announcementsFetch'
import hookAnnouncement from '@utils/queryHooks/announcement'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useState } from 'react'
@ -19,7 +19,7 @@ import {
} from 'react-native'
import { FlatList, ScrollView } from 'react-native-gesture-handler'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useMutation, useQuery } from 'react-query'
import { useMutation } from 'react-query'
const fireMutation = async ({
announcementId,
@ -34,13 +34,13 @@ const fireMutation = async ({
}) => {
switch (type) {
case 'reaction':
return client({
return client<{}>({
method: me ? 'delete' : 'put',
instance: 'local',
url: `announcements/${announcementId}/reactions/${name}`
})
case 'dismiss':
return client({
return client<{}>({
method: 'post',
instance: 'local',
url: `announcements/${announcementId}/dismiss`
@ -59,12 +59,14 @@ const ScreenSharedAnnouncements: React.FC = ({
const [index, setIndex] = useState(0)
const { t, i18n } = useTranslation()
const queryKey = ['Announcements', { showAll }]
const { data, refetch } = useQuery(queryKey, announcementFetch, {
select: announcements =>
announcements.filter(announcement =>
showAll ? announcement : !announcement.read
)
const { data, refetch } = hookAnnouncement({
showAll,
options: {
select: announcements =>
announcements.filter(announcement =>
showAll ? announcement : !announcement.read
)
}
})
const queryMutation = useMutation(fireMutation, {
onSettled: () => {

View File

@ -4,7 +4,7 @@ import { store } from '@root/store'
import layoutAnimation from '@root/utils/styles/layoutAnimation'
import formatText from '@screens/Shared/Compose/formatText'
import ComposeRoot from '@screens/Shared/Compose/Root'
import { getLocalAccountPreferences } from '@utils/slices/instancesSlice'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
@ -24,7 +24,6 @@ import composeInitialState from './Compose/utils/initialState'
import composeParseState from './Compose/utils/parseState'
import composePost from './Compose/utils/post'
import composeReducer from './Compose/utils/reducer'
import { ComposeState } from './Compose/utils/types'
const Stack = createNativeStackNavigator()
@ -34,7 +33,6 @@ export interface Props {
| {
type?: 'reply' | 'conversation' | 'edit'
incomingStatus: Mastodon.Status
visibilityLock?: boolean
}
| undefined
}
@ -63,19 +61,21 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
setHasKeyboard(false)
}
const localAccount = getLocalAccount(store.getState())
const [composeState, composeDispatch] = useReducer(
composeReducer,
params?.type && params?.incomingStatus
? composeParseState({
type: params.type,
incomingStatus: params.incomingStatus,
visibilityLock: params.visibilityLock
incomingStatus: params.incomingStatus
})
: {
...composeInitialState,
visibility: getLocalAccountPreferences(store.getState())[
'posting:default:visibility'
] as ComposeState['visibility']
visibility:
localAccount?.preferences &&
localAccount.preferences['posting:default:visibility']
? localAccount.preferences['posting:default:visibility']
: 'public'
}
)
const [isSubmitting, setIsSubmitting] = useState(false)

View File

@ -97,7 +97,7 @@ const ComposeEditAttachment: React.FC<Props> = ({
formData.append('focus', `${focus.current.x},${focus.current.y}`)
}
client({
client<Mastodon.Attachment>({
method: 'put',
instance: 'local',
url: `media/${theAttachment.id}`,

View File

@ -1,131 +1,43 @@
import haptics from '@components/haptics'
import { ParseEmojis } from '@components/Parse'
import { emojisFetch } from '@utils/fetches/emojisFetch'
import { searchFetch } from '@utils/fetches/searchFetch'
import hookEmojis from '@utils/queryHooks/emojis'
import hookSearch from '@utils/queryHooks/search'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { forEach, groupBy, sortBy } from 'lodash'
import React, {
Dispatch,
useCallback,
useContext,
useEffect,
useMemo
} from 'react'
import {
View,
FlatList,
Pressable,
StyleSheet,
Text,
Image
} from 'react-native'
import React, { useCallback, useContext, useEffect, useMemo } from 'react'
import { View, FlatList, StyleSheet } from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
import { useQuery } from 'react-query'
import ComposeActions from './Actions'
import ComposeRootFooter from './Root/Footer'
import ComposeRootHeader from './Root/Header'
import updateText from './updateText'
import ComposeRootSuggestion from './Root/Suggestion'
import ComposeContext from './utils/createContext'
import { ComposeAction, ComposeState } from './utils/types'
const ListItem = React.memo(
({
item,
composeState,
composeDispatch
}: {
item: Mastodon.Account & Mastodon.Tag
composeState: ComposeState
composeDispatch: Dispatch<ComposeAction>
}) => {
const { theme } = useTheme()
const onPress = useCallback(() => {
const focusedInput = composeState.textInputFocus.current
updateText({
composeState: {
...composeState,
[focusedInput]: {
...composeState[focusedInput],
selection: {
start: composeState.tag!.offset,
end: composeState.tag!.offset + composeState.tag!.text.length + 1
}
}
},
composeDispatch,
newText: item.acct ? `@${item.acct}` : `#${item.name}`,
type: 'suggestion'
})
haptics('Success')
}, [])
const children = useMemo(
() =>
item.acct ? (
<View style={[styles.account, { borderBottomColor: theme.border }]}>
<Image source={{ uri: item.avatar }} style={styles.accountAvatar} />
<View>
<Text
style={[styles.accountName, { color: theme.primary }]}
numberOfLines={1}
>
<ParseEmojis
content={item.display_name || item.username}
emojis={item.emojis}
size='S'
/>
</Text>
<Text
style={[styles.accountAccount, { color: theme.primary }]}
numberOfLines={1}
>
@{item.acct}
</Text>
</View>
</View>
) : (
<View style={[styles.hashtag, { borderBottomColor: theme.border }]}>
<Text style={[styles.hashtagText, { color: theme.primary }]}>
#{item.name}
</Text>
</View>
),
[]
)
return (
<Pressable
onPress={onPress}
style={styles.suggestion}
children={children}
/>
)
},
() => true
)
const ComposeRoot: React.FC = () => {
const { theme } = useTheme()
const { composeState, composeDispatch } = useContext(ComposeContext)
const { isFetching, data, refetch } = useQuery(
[
'Search',
{
type: composeState.tag?.type,
term: composeState.tag?.text.substring(1)
}
],
searchFetch,
{ enabled: false }
)
const { isFetching, data, refetch } = hookSearch({
type:
composeState.tag?.type === 'accounts' ||
composeState.tag?.type === 'hashtags'
? composeState.tag.type
: undefined,
term: composeState.tag?.text.substring(1),
options: { enabled: false }
})
useEffect(() => {
if (composeState.tag?.text) {
if (
(composeState.tag?.type === 'accounts' ||
composeState.tag?.type === 'hashtags') &&
composeState.tag?.text
) {
refetch()
}
}, [composeState.tag?.text])
}, [composeState.tag])
const { data: emojisData } = useQuery(['Emojis'], emojisFetch)
const { data: emojisData } = hookEmojis({})
useEffect(() => {
if (emojisData && emojisData.length) {
let sortedEmojis: { title: string; data: Mastodon.Emoji[] }[] = []
@ -155,7 +67,7 @@ const ComposeRoot: React.FC = () => {
const listItem = useCallback(
({ item }) => (
<ListItem
<ComposeRootSuggestion
item={item}
composeState={composeState}
composeDispatch={composeDispatch}
@ -167,13 +79,14 @@ const ComposeRoot: React.FC = () => {
return (
<View style={styles.base}>
<FlatList
renderItem={listItem}
ListEmptyComponent={listEmpty}
keyboardShouldPersistTaps='handled'
ListHeaderComponent={ComposeRootHeader}
ListFooterComponent={ComposeRootFooter}
ListEmptyComponent={listEmpty}
data={data as Mastodon.Account[] & Mastodon.Tag[]}
renderItem={listItem}
keyExtractor={(item: any) => item.acct || item.name}
// @ts-ignore
data={data ? data[composeState.tag?.type] : undefined}
keyExtractor={({ item }) => item.acct || item.name}
/>
<ComposeActions />
</View>
@ -185,46 +98,6 @@ const styles = StyleSheet.create({
flex: 1
},
contentView: { flex: 1 },
suggestion: {
flex: 1
},
account: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginRight: StyleConstants.Spacing.Global.PagePadding,
borderBottomWidth: StyleSheet.hairlineWidth
},
accountAvatar: {
width: StyleConstants.Font.LineHeight.M * 2,
height: StyleConstants.Font.LineHeight.M * 2,
marginRight: StyleConstants.Spacing.S,
borderRadius: StyleConstants.Avatar.M
},
accountName: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
},
accountAccount: {
...StyleConstants.FontStyle.S
},
hashtag: {
flex: 1,
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginRight: StyleConstants.Spacing.Global.PagePadding,
borderBottomWidth: StyleSheet.hairlineWidth
},
hashtagText: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
},
loading: {
flex: 1,
alignItems: 'center'

View File

@ -0,0 +1,127 @@
import haptics from '@components/haptics'
import { ParseEmojis } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { Dispatch, useCallback, useMemo } from 'react'
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'
import updateText from '../updateText'
import { ComposeAction, ComposeState } from '../utils/types'
const ComposeRootSuggestion = React.memo(
({
item,
composeState,
composeDispatch
}: {
item: Mastodon.Account & Mastodon.Tag
composeState: ComposeState
composeDispatch: Dispatch<ComposeAction>
}) => {
const { theme } = useTheme()
const onPress = useCallback(() => {
const focusedInput = composeState.textInputFocus.current
updateText({
composeState: {
...composeState,
[focusedInput]: {
...composeState[focusedInput],
selection: {
start: composeState.tag!.offset,
end: composeState.tag!.offset + composeState.tag!.text.length + 1
}
}
},
composeDispatch,
newText: item.acct ? `@${item.acct}` : `#${item.name}`,
type: 'suggestion'
})
haptics('Light')
}, [])
const children = useMemo(
() =>
item.acct ? (
<View style={[styles.account, { borderBottomColor: theme.border }]}>
<Image source={{ uri: item.avatar }} style={styles.accountAvatar} />
<View>
<Text
style={[styles.accountName, { color: theme.primary }]}
numberOfLines={1}
>
<ParseEmojis
content={item.display_name || item.username}
emojis={item.emojis}
size='S'
/>
</Text>
<Text
style={[styles.accountAccount, { color: theme.primary }]}
numberOfLines={1}
>
@{item.acct}
</Text>
</View>
</View>
) : (
<View style={[styles.hashtag, { borderBottomColor: theme.border }]}>
<Text style={[styles.hashtagText, { color: theme.primary }]}>
#{item.name}
</Text>
</View>
),
[]
)
return (
<Pressable
onPress={onPress}
style={styles.suggestion}
children={children}
/>
)
},
() => true
)
const styles = StyleSheet.create({
suggestion: {
flex: 1
},
account: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginRight: StyleConstants.Spacing.Global.PagePadding,
borderBottomWidth: StyleSheet.hairlineWidth
},
accountAvatar: {
width: StyleConstants.Font.LineHeight.M * 2,
height: StyleConstants.Font.LineHeight.M * 2,
marginRight: StyleConstants.Spacing.S,
borderRadius: StyleConstants.Avatar.M
},
accountName: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
},
accountAccount: {
...StyleConstants.FontStyle.S
},
hashtag: {
flex: 1,
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginRight: StyleConstants.Spacing.Global.PagePadding,
borderBottomWidth: StyleSheet.hairlineWidth
},
hashtagText: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginBottom: StyleConstants.Spacing.XS
}
})
export default ComposeRootSuggestion

View File

@ -67,17 +67,17 @@ const addAttachment = async ({ composeDispatch }: Props): Promise<any> => {
type: 'image/jpeg/jpg'
})
return client({
return client<Mastodon.Attachment>({
method: 'post',
instance: 'local',
url: 'media',
body: formData
})
.then(({ body }: { body: Mastodon.Attachment }) => {
if (body.id) {
.then(res => {
if (res.id) {
composeDispatch({
type: 'attachment/upload/end',
payload: { remote: body, local: result }
payload: { remote: res, local: result }
})
} else {
composeDispatch({

View File

@ -1,5 +1,5 @@
import { store } from '@root/store'
import { getLocalAccountPreferences } from '@utils/slices/instancesSlice'
import { getLocalAccount } from '@utils/slices/instancesSlice'
import composeInitialState from './initialState'
import { ComposeState } from './types'
@ -40,9 +40,10 @@ const composeParseState = ({ type, incomingStatus }: Props): ComposeState => {
}),
visibility:
incomingStatus.visibility ||
getLocalAccountPreferences(store.getState())[
getLocalAccount(store.getState())?.preferences[
'posting:default:visibility'
],
] ||
'public',
...(incomingStatus.visibility === 'direct' && { visibilityLock: true })
}
case 'reply':

View File

@ -40,7 +40,7 @@ const composePost = async (
formData.append('visibility', composeState.visibility)
return client({
return client<Mastodon.Status>({
method: 'post',
instance: 'local',
url: 'statuses',

View File

@ -20,7 +20,6 @@ const ScreenSharedRelationships: React.FC<Props> = ({
params: { account, initialType }
}
}) => {
console.log(account.id)
const { mode } = useTheme()
const navigation = useNavigation()

View File

@ -3,11 +3,10 @@ import ComponentSeparator from '@components/Separator'
import TimelineEmpty from '@components/Timelines/Timeline/Empty'
import TimelineEnd from '@root/components/Timelines/Timeline/End'
import { useScrollToTop } from '@react-navigation/native'
import { relationshipsFetch } from '@utils/fetches/relationshipsFetch'
import React, { useCallback, useMemo, useRef } from 'react'
import { RefreshControl, StyleSheet } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { useInfiniteQuery } from 'react-query'
import hookRelationships from '@utils/queryHooks/relationships'
export interface Props {
id: Mastodon.Account['id']
@ -15,8 +14,6 @@ export interface Props {
}
const RelationshipsList: React.FC<Props> = ({ id, type }) => {
const queryKey: QueryKey.Relationships = ['Relationships', type, { id }]
const {
status,
data,
@ -25,14 +22,18 @@ const RelationshipsList: React.FC<Props> = ({ id, type }) => {
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(queryKey, relationshipsFetch, {
getNextPageParam: lastPage => {
return lastPage.length
? {
direction: 'next',
id: lastPage[lastPage.length - 1].id
}
: undefined
} = hookRelationships({
type,
id,
options: {
getNextPageParam: lastPage => {
return lastPage.length
? {
direction: 'next',
id: lastPage[lastPage.length - 1].id
}
: undefined
}
}
})
const flattenData = data?.pages ? data.pages.flatMap(d => [...d]) : []

View File

@ -2,7 +2,7 @@ import { useNavigation } from '@react-navigation/native'
import ComponentAccount from '@root/components/Account'
import ComponentSeparator from '@root/components/Separator'
import TimelineDefault from '@root/components/Timelines/Timeline/Default'
import { searchFetch } from '@utils/fetches/searchFetch'
import hookSearch from '@utils/queryHooks/search'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { debounce } from 'lodash'
@ -17,17 +17,15 @@ import {
} from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
import { TextInput } from 'react-native-gesture-handler'
import { useQuery } from 'react-query'
const ScreenSharedSearch: React.FC = () => {
const navigation = useNavigation()
const { theme } = useTheme()
const [searchTerm, setSearchTerm] = useState<string | undefined>()
const { status, data, refetch } = useQuery(
['Search', { term: searchTerm }],
searchFetch,
{ enabled: false }
)
const { status, data, refetch } = hookSearch({
term: searchTerm,
options: { enabled: false }
})
useEffect(() => {
const updateHeaderRight = () =>

View File

@ -1,4 +1,4 @@
import { HeaderLeft, HeaderRight } from '@components/Header'
import { HeaderLeft } from '@components/Header'
import ScreenSharedAccount from '@screens/Shared/Account'
import ScreenSharedAnnouncements from '@screens/Shared/Announcements'
import ScreenSharedHashtag from '@screens/Shared/Hashtag'
@ -9,7 +9,6 @@ import Compose from '@screens/Shared/Compose'
import ScreenSharedSearch from '@screens/Shared/Search'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { View } from 'react-native'
const sharedScreens = (Stack: any) => {
const { t } = useTranslation()

0
src/startup/Main.tsx Normal file
View File

12
src/startup/audio.ts Normal file
View File

@ -0,0 +1,12 @@
import { Audio } from 'expo-av'
import log from "./log"
const audio = () => {
log('log', 'audio', 'setting audio playback default options')
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
interruptionModeIOS: 1
})
}
export default audio

View File

@ -0,0 +1,41 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { persistor } from '@root/store'
import log from './log'
// Used to upgrade/invalidate secure storage
const dataKey = '@mastodon_app_database_version'
const currentVersion = '20210105'
const checkSecureStorageVersion = async (): Promise<any> => {
log(
'log',
'checkSecureStorageVersion',
'Start checking secure storage version'
)
try {
const value = await AsyncStorage.getItem(dataKey)
if (value !== currentVersion) {
log(
'warn',
'checkSecureStorageVersion',
`Version does not match. Prev: ${value}. Current: ${currentVersion}.`
)
persistor.purge()
try {
await AsyncStorage.setItem(dataKey, currentVersion)
} catch (e) {
log('error', 'checkSecureStorageVersion', 'Storing storage data error')
return Promise.reject()
}
} else {
log('log', 'checkSecureStorageVersion', 'Storing storage version matched')
}
return Promise.resolve()
} catch (e) {
log('error', 'checkSecureStorageVersion', 'Getting storage data error')
return Promise.reject()
}
}
export default checkSecureStorageVersion

18
src/startup/dev.ts Normal file
View File

@ -0,0 +1,18 @@
import * as Analytics from 'expo-firebase-analytics'
import React from 'react'
import log from './log'
const dev = () => {
if (__DEV__) {
Analytics.setDebugModeEnabled(true)
log('log', 'devs', 'initializing wdyr')
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackHooks: true,
hotReloadBufferMs: 1000
})
}
}
export default dev

37
src/startup/log.ts Normal file
View File

@ -0,0 +1,37 @@
import chalk from 'chalk'
const ctx = new chalk.Instance({ level: 3 })
const log = (type: 'log' | 'warn' | 'error', func: string, message: string) => {
switch (type) {
case 'log':
console.log(
ctx.bgBlue.white.bold(' Start up ') +
' ' +
ctx.bgBlueBright.black(` ${func} `) +
' ' +
message
)
break
case 'warn':
console.warn(
ctx.bgYellow.white.bold(' Start up ') +
' ' +
ctx.bgYellowBright.black(` ${func} `) +
' ' +
message
)
break
case 'error':
console.error(
ctx.bgRed.white.bold(' Start up ') +
' ' +
ctx.bgRedBright.black(` ${func} `) +
' ' +
message
)
break
}
}
export default log

61
src/startup/netInfo.ts Normal file
View File

@ -0,0 +1,61 @@
import client from '@api/client'
import NetInfo from '@react-native-community/netinfo'
import { store } from '@root/store'
import { localRemoveInstance } from '@utils/slices/instancesSlice'
import log from './log'
const netInfo = async (): Promise<{
connected: boolean
corrupted?: string
}> => {
log('log', 'netInfo', 'initializing')
const netInfo = await NetInfo.fetch()
const activeIndex = store.getState().instances.local?.activeIndex
if (netInfo.isConnected) {
log('log', 'netInfo', 'network connected')
if (activeIndex) {
log('log', 'netInfo', 'checking locally stored credentials')
return client<Mastodon.Account>({
method: 'get',
instance: 'local',
url: `accounts/verify_credentials`
})
.then(res => {
log('log', 'netInfo', 'local credential check passed')
if (
res.id !==
store.getState().instances.local?.instances[activeIndex].account.id
) {
log('error', 'netInfo', 'local id does not match remote id')
store.dispatch(localRemoveInstance(activeIndex))
return Promise.resolve({ connected: true, corruputed: '' })
} else {
return Promise.resolve({ connected: true })
}
})
.catch(error => {
log('error', 'netInfo', 'local credential check failed')
if (
error.status &&
typeof error.status === 'number' &&
error.status === 401
) {
store.dispatch(localRemoveInstance(activeIndex))
}
return Promise.resolve({
connected: true,
corrupted: error.data.error
})
})
} else {
log('log', 'netInfo', 'no local credential found')
return Promise.resolve({ connected: true })
}
} else {
log('warn', 'netInfo', 'network not connected')
return Promise.resolve({ connected: true })
}
}
export default netInfo

View File

@ -0,0 +1,15 @@
import NetInfo from '@react-native-community/netinfo'
import { onlineManager } from 'react-query'
import log from './log'
const onlineStatus = () =>
onlineManager.setEventListener(setOnline => {
log('log', 'onlineStatus', 'added onlineManager listener')
return NetInfo.addEventListener(state => {
log('log', 'onlineStatus', `setting online state ${state.isConnected}`)
// @ts-ignore
setOnline(state.isConnected)
})
})
export default onlineStatus

14
src/startup/sentry.ts Normal file
View File

@ -0,0 +1,14 @@
import * as Sentry from 'sentry-expo'
import log from "./log"
const sentry = () => {
log('log', 'Sentry', 'initializing')
Sentry.init({
dsn:
'https://c9e29aa05f774aca8f36def98244ce04@o389581.ingest.sentry.io/5571975',
enableInExpoDevelopment: false,
debug: __DEV__
})
}
export default sentry

View File

@ -1,27 +1,46 @@
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
combineReducers,
configureStore,
getDefaultMiddleware
} from '@reduxjs/toolkit'
import instancesSlice from '@utils/slices/instancesSlice'
import settingsSlice from '@utils/slices/settingsSlice'
import { persistReducer, persistStore } from 'redux-persist'
import createSecureStore from 'redux-persist-expo-securestore'
import instancesSlice from '@utils/slices/instancesSlice'
import settingsSlice from '@utils/slices/settingsSlice'
const secureStorage = createSecureStore()
const prefix = 'mastodon_app'
const instancesPersistConfig = {
key: 'instances',
storage: secureStorage
prefix,
version: 1,
storage: secureStorage,
debug: true
}
const settingsPersistConfig = {
key: 'settings',
prefix,
storage: secureStorage
}
const rootPersistConfig = {
key: 'root',
prefix,
version: 0,
storage: AsyncStorage
}
const rootReducer = combineReducers({
instances: persistReducer(instancesPersistConfig, instancesSlice),
settings: persistReducer(settingsPersistConfig, settingsSlice)
})
const store = configureStore({
reducer: {
instances: persistReducer(instancesPersistConfig, instancesSlice),
settings: persistReducer(settingsPersistConfig, settingsSlice)
},
reducer: persistReducer(rootPersistConfig, rootReducer),
middleware: getDefaultMiddleware({
serializableCheck: {
ignoredActions: ['persist/PERSIST']

View File

@ -1,16 +0,0 @@
import client from '@api/client'
export const accountFetch = async ({
queryKey
}: {
queryKey: QueryKey.Account
}): Promise<Mastodon.Account> => {
const [_, { id }] = queryKey
const res = await client({
method: 'get',
instance: 'local',
url: `accounts/${id}`
})
return Promise.resolve(res.body)
}

View File

@ -1,21 +0,0 @@
import client from '@api/client'
export const announcementFetch = async ({
queryKey
}: {
queryKey: QueryKey.Announcements
}): Promise<Mastodon.Announcement[]> => {
const [_, { showAll }] = queryKey
const res = await client({
method: 'get',
instance: 'local',
url: `announcements`,
...(showAll && {
params: {
with_dismissed: 'true'
}
})
})
return Promise.resolve(res.body)
}

View File

@ -1,23 +0,0 @@
import client from '@api/client'
export const applicationFetch = async ({
queryKey
}: {
queryKey: QueryKey.Application
}): Promise<Mastodon.AppOauth> => {
const [_, { instanceDomain }] = queryKey
const formData = new FormData()
formData.append('client_name', 'test_dudu')
formData.append('redirect_uris', 'exp://127.0.0.1:19000')
formData.append('scopes', 'read write follow push')
const res = await client({
method: 'post',
instance: 'remote',
instanceDomain,
url: `apps`,
body: formData
})
return Promise.resolve(res.body)
}

View File

@ -1,10 +0,0 @@
import client from '@api/client'
export const emojisFetch = async (): Promise<Mastodon.Emoji[]> => {
const res = await client({
method: 'get',
instance: 'local',
url: 'custom_emojis'
})
return Promise.resolve(res.body)
}

View File

@ -1,17 +0,0 @@
import client from '@api/client'
export const instanceFetch = async ({
queryKey
}: {
queryKey: QueryKey.Instance
}): Promise<Mastodon.Instance> => {
const [_, { instanceDomain }] = queryKey
const res = await client({
method: 'get',
instance: 'remote',
instanceDomain,
url: `instance`
})
return Promise.resolve(res.body)
}

View File

@ -1,10 +0,0 @@
import client from '@api/client'
export const listsFetch = async (): Promise<Mastodon.List[]> => {
const res = await client({
method: 'get',
instance: 'local',
url: 'lists'
})
return Promise.resolve(res.body)
}

View File

@ -1,19 +0,0 @@
import client from '@api/client'
export const relationshipFetch = async ({
queryKey
}: {
queryKey: QueryKey.Relationship
}): Promise<Mastodon.Relationship> => {
const [_, { id }] = queryKey
const res = await client({
method: 'get',
instance: 'local',
url: `accounts/relationships`,
params: {
'id[]': id
}
})
return Promise.resolve(res.body[0])
}

View File

@ -1,28 +0,0 @@
import client from '@api/client'
export const relationshipsFetch = async ({
queryKey,
pageParam
}: {
queryKey: QueryKey.Relationships
pageParam?: { direction: 'next'; id: Mastodon.Status['id'] }
}): Promise<Mastodon.Account[]> => {
const [_, type, { id }] = queryKey
let params: { [key: string]: string } = {}
if (pageParam) {
switch (pageParam.direction) {
case 'next':
params.max_id = pageParam.id
break
}
}
const res = await client({
method: 'get',
instance: 'local',
url: `accounts/${id}/${type}`,
params
})
return Promise.resolve(res.body)
}

View File

@ -1,30 +0,0 @@
import client from '@api/client'
export const searchFetch = async ({
queryKey
}: {
queryKey: QueryKey.Search
}): Promise<
| Mastodon.Account[]
| Mastodon.Tag[]
| Mastodon.Status[]
| {
accounts: Mastodon.Account[]
hashtags: Mastodon.Tag[]
statuses: Mastodon.Status[]
}
> => {
const [_, { type, term, limit = 20 }] = queryKey
const res = await client({
version: 'v2',
method: 'get',
instance: 'local',
url: 'search',
params: { ...(type && { type }), q: term, limit }
})
if (type) {
return Promise.resolve(res.body[type])
} else {
return Promise.resolve(res.body)
}
}

View File

@ -1,212 +0,0 @@
import { uniqBy } from 'lodash'
import client from '@api/client'
export const timelineFetch = async ({
queryKey,
pageParam
}: {
queryKey: QueryKey.Timeline
pageParam?: { direction: 'prev' | 'next'; id: Mastodon.Status['id'] }
}): Promise<{
toots: Mastodon.Status[]
pointer?: number
pinnedLength?: number
}> => {
const [page, { account, hashtag, list, toot }] = queryKey
let res
let params: { [key: string]: string } = {}
if (pageParam) {
switch (pageParam.direction) {
case 'prev':
if (page === 'Bookmarks' || page === 'Favourites') {
params.max_id = pageParam.id
} else {
params.min_id = pageParam.id
}
break
case 'next':
if (page === 'Bookmarks' || page === 'Favourites') {
params.min_id = pageParam.id
} else {
params.max_id = pageParam.id
}
break
}
}
switch (page) {
case 'Following':
res = await client({
method: 'get',
instance: 'local',
url: 'timelines/home',
params
})
return Promise.resolve({ toots: res.body })
case 'Local':
params.local = 'true'
res = await client({
method: 'get',
instance: 'local',
url: 'timelines/public',
params
})
return Promise.resolve({ toots: res.body })
case 'LocalPublic':
res = await client({
method: 'get',
instance: 'local',
url: 'timelines/public',
params
})
return Promise.resolve({ toots: res.body })
case 'RemotePublic':
res = await client({
method: 'get',
instance: 'remote',
url: 'timelines/public',
params
})
return Promise.resolve({ toots: res.body })
case 'Notifications':
res = await client({
method: 'get',
instance: 'local',
url: 'notifications',
params
})
return Promise.resolve({ toots: res.body })
case 'Account_Default':
if (pageParam && pageParam.direction === 'next') {
res = await client({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
exclude_replies: 'true',
...params
}
})
return Promise.resolve({ toots: res.body })
} else {
res = await client({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
pinned: 'true'
}
})
const pinnedLength = res.body.length
let toots: Mastodon.Status[] = res.body
res = await client({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
exclude_replies: 'true'
}
})
toots = uniqBy([...toots, ...res.body], 'id')
return Promise.resolve({ toots: toots, pinnedLength })
}
case 'Account_All':
res = await client({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params
})
return Promise.resolve({ toots: res.body })
case 'Account_Media':
res = await client({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
only_media: 'true',
...params
}
})
return Promise.resolve({ toots: res.body })
case 'Hashtag':
res = await client({
method: 'get',
instance: 'local',
url: `timelines/tag/${hashtag}`,
params
})
return Promise.resolve({ toots: res.body })
case 'Conversations':
res = await client({
method: 'get',
instance: 'local',
url: `conversations`,
params
})
if (pageParam) {
// Bug in pull to refresh in conversations
res.body = res.body.filter(
(b: Mastodon.Conversation) => b.id !== pageParam.id
)
}
return Promise.resolve({ toots: res.body })
case 'Bookmarks':
res = await client({
method: 'get',
instance: 'local',
url: `bookmarks`,
params
})
return Promise.resolve({ toots: res.body })
case 'Favourites':
res = await client({
method: 'get',
instance: 'local',
url: `favourites`,
params
})
return Promise.resolve({ toots: res.body })
case 'List':
res = await client({
method: 'get',
instance: 'local',
url: `timelines/list/${list}`,
params
})
return Promise.resolve({ toots: res.body })
case 'Toot':
res = await client({
method: 'get',
instance: 'local',
url: `statuses/${toot}`
})
const theToot = res.body
res = await client({
method: 'get',
instance: 'local',
url: `statuses/${toot}/context`
})
return Promise.resolve({
toots: [...res.body.ancestors, theToot, ...res.body.descendants],
pointer: res.body.ancestors.length
})
default:
return Promise.reject()
}
}

View File

@ -0,0 +1,27 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Account', { id: Mastodon.Account['id'] }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { id } = queryKey[1]
return client<Mastodon.Account>({
method: 'get',
instance: 'local',
url: `accounts/${id}`
})
}
const hookAccount = <TData = Mastodon.Account>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Account, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Account', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookAccount

View File

@ -0,0 +1,35 @@
import client from '@api/client'
import { InstancesState } from '@utils/slices/instancesSlice'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = [
'AccountCheck',
{
id: Mastodon.Account['id']
index: NonNullable<InstancesState['local']['activeIndex']>
}
]
const queryFunction = async ({ queryKey }: { queryKey: QueryKey }) => {
const { id, index } = queryKey[1]
return client<Mastodon.Account>({
method: 'get',
instance: 'local',
localIndex: index,
url: `accounts/${id}`
})
}
const hookAccountCheck = <TData = Mastodon.Account>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Account, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['AccountCheck', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookAccountCheck

View File

@ -0,0 +1,32 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
type QueryKey = ['Announcements', { showAll?: boolean }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { showAll } = queryKey[1]
return client<Mastodon.Announcement[]>({
method: 'get',
instance: 'local',
url: `announcements`,
...(showAll && {
params: {
with_dismissed: 'true'
}
})
})
}
const hookAnnouncement = <TData = Mastodon.Announcement[]>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Announcement[], AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Announcements', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookAnnouncement

View File

@ -0,0 +1,34 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Apps', { instanceDomain?: string }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { instanceDomain } = queryKey[1]
const formData = new FormData()
formData.append('client_name', 'test_dudu')
formData.append('redirect_uris', 'exp://127.0.0.1:19000')
formData.append('scopes', 'read write follow push')
return client<Mastodon.Apps>({
method: 'post',
instance: 'remote',
instanceDomain,
url: `apps`,
body: formData
})
}
const hookApps = <TData = Mastodon.Apps>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Apps, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Apps', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookApps

View File

@ -0,0 +1,24 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
type QueryKey = ['Emojis']
const queryFunction = () => {
return client<Mastodon.Emoji[]>({
method: 'get',
instance: 'local',
url: 'custom_emojis'
})
}
const hookEmojis = <TData = Mastodon.Emoji[]>({
options
}: {
options?: UseQueryOptions<Mastodon.Emoji[], AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Emojis']
return useQuery(queryKey, queryFunction, options)
}
export default hookEmojis

View File

@ -0,0 +1,28 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Instance', { instanceDomain?: string }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { instanceDomain } = queryKey[1]
return client<Mastodon.Instance>({
method: 'get',
instance: 'remote',
instanceDomain,
url: `instance`
})
}
const hookInstance = <TData = Mastodon.Instance>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Instance, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Instance', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookInstance

View File

@ -0,0 +1,24 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Lists']
const queryFunction = () => {
return client<Mastodon.List[]>({
method: 'get',
instance: 'local',
url: 'lists'
})
}
const hookLists = <TData = Mastodon.List[]>({
options
}: {
options?: UseQueryOptions<Mastodon.List[], AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Lists']
return useQuery(queryKey, queryFunction, options)
}
export default hookLists

View File

@ -0,0 +1,30 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Relationship', { id: Mastodon.Account['id'] }]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { id } = queryKey[1]
return client<Mastodon.Relationship>({
method: 'get',
instance: 'local',
url: `accounts/relationships`,
params: {
'id[]': id
}
})
}
const hookRelationship = <TData = Mastodon.Relationship>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Relationship, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Relationship', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookRelationship

View File

@ -0,0 +1,46 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useInfiniteQuery, UseInfiniteQueryOptions } from 'react-query'
export type QueryKey = [
'Relationships',
{ type: 'following' | 'followers'; id: Mastodon.Account['id'] }
]
const queryFunction = ({
queryKey,
pageParam
}: {
queryKey: QueryKey
pageParam?: { direction: 'next'; id: Mastodon.Status['id'] }
}) => {
const { type, id } = queryKey[1]
let params: { [key: string]: string } = {}
if (pageParam) {
switch (pageParam.direction) {
case 'next':
params.max_id = pageParam.id
break
}
}
return client<Mastodon.Account[]>({
method: 'get',
instance: 'local',
url: `accounts/${id}/${type}`,
params
})
}
const hookRelationships = <TData = Mastodon.Account[]>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseInfiniteQueryOptions<Mastodon.Account[], AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Relationships', { ...queryKeyParams }]
return useInfiniteQuery(queryKey, queryFunction, options)
}
export default hookRelationships

View File

@ -0,0 +1,41 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = [
'Search',
{
type?: 'accounts' | 'hashtags' | 'statuses'
term?: string
limit?: number
}
]
type SearchResult = {
accounts: Mastodon.Account[]
hashtags: Mastodon.Tag[]
statuses: Mastodon.Status[]
}
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { type, term, limit = 20 } = queryKey[1]
return client<SearchResult>({
version: 'v2',
method: 'get',
instance: 'local',
url: 'search',
params: { ...(type && { type }), ...(term && { q: term }), limit }
})
}
const hookSearch = <TData = SearchResult>({
options,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<SearchResult, AxiosError, TData>
}) => {
const queryKey: QueryKey = ['Search', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export default hookSearch

View File

@ -0,0 +1,291 @@
import client from '@api/client'
import { AxiosError } from 'axios'
import { uniqBy } from 'lodash'
import { useInfiniteQuery, UseInfiniteQueryOptions } from 'react-query'
export type QueryKeyTimeline = [
'Timeline',
{
page: App.Pages
hashtag?: Mastodon.Tag['name']
list?: Mastodon.List['id']
toot?: Mastodon.Status['id']
account?: Mastodon.Account['id']
}
]
const queryFunction = async ({
queryKey,
pageParam
}: {
queryKey: QueryKeyTimeline
pageParam?: { direction: 'prev' | 'next'; id: Mastodon.Status['id'] }
}) => {
const { page, account, hashtag, list, toot } = queryKey[1]
let res
let params: { [key: string]: string } = {}
if (pageParam) {
switch (pageParam.direction) {
case 'prev':
if (page === 'Bookmarks' || page === 'Favourites') {
params.max_id = pageParam.id
} else {
params.min_id = pageParam.id
}
break
case 'next':
if (page === 'Bookmarks' || page === 'Favourites') {
params.min_id = pageParam.id
} else {
params.max_id = pageParam.id
}
break
}
}
switch (page) {
case 'Following':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: 'timelines/home',
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Local':
params.local = 'true'
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: 'timelines/public',
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'LocalPublic':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: 'timelines/public',
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'RemotePublic':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'remote',
url: 'timelines/public',
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Notifications':
res = await client<Mastodon.Notification[]>({
method: 'get',
instance: 'local',
url: 'notifications',
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Account_Default':
if (pageParam && pageParam.direction === 'next') {
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
exclude_replies: 'true',
...params
}
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
} else {
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
pinned: 'true'
}
})
const pinnedLength = res.length
let toots = res
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
exclude_replies: 'true'
}
})
toots = uniqBy([...toots, ...res], 'id')
return Promise.resolve({
toots: toots,
pointer: undefined,
pinnedLength
})
}
case 'Account_All':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Account_Media':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `accounts/${account}/statuses`,
params: {
only_media: 'true',
...params
}
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Hashtag':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `timelines/tag/${hashtag}`,
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Conversations':
res = await client<Mastodon.Conversation[]>({
method: 'get',
instance: 'local',
url: `conversations`,
params
})
if (pageParam) {
// Bug in pull to refresh in conversations
res = res.filter(b => b.id !== pageParam.id)
}
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Bookmarks':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `bookmarks`,
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Favourites':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `favourites`,
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'List':
res = await client<Mastodon.Status[]>({
method: 'get',
instance: 'local',
url: `timelines/list/${list}`,
params
})
return Promise.resolve({
toots: res,
pointer: undefined,
pinnedLength: undefined
})
case 'Toot':
res = await client<Mastodon.Status>({
method: 'get',
instance: 'local',
url: `statuses/${toot}`
})
const theToot = res
res = await client<{
ancestors: Mastodon.Status[]
descendants: Mastodon.Status[]
}>({
method: 'get',
instance: 'local',
url: `statuses/${toot}/context`
})
return Promise.resolve({
toots: [...res.ancestors, theToot, ...res.descendants],
pointer: res.ancestors.length,
pinnedLength: undefined
})
default:
return Promise.reject()
}
}
type Unpromise<T extends Promise<any>> = T extends Promise<infer U> ? U : never
const hookTimeline = <TData = Unpromise<ReturnType<typeof queryFunction>>>({
options,
...queryKeyParams
}: QueryKeyTimeline[1] & {
options?: UseInfiniteQueryOptions<any, AxiosError, TData>
}) => {
const queryKey: QueryKeyTimeline = ['Timeline', { ...queryKeyParams }]
return useInfiniteQuery(queryKey, queryFunction, options)
}
export default hookTimeline

View File

@ -1,70 +1,65 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import client from '@api/client'
import analytics from '@components/analytics'
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store'
import * as AuthSession from 'expo-auth-session'
export type InstanceLocal = {
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
account: {
id: Mastodon.Account['id']
preferences: Mastodon.Preferences
}
notification: {
unread: boolean
latestTime?: Mastodon.Notification['created_at']
}
}
export type InstancesState = {
local: {
url: string | undefined
token: string | undefined
account: {
id: Mastodon.Account['id'] | undefined
preferences: Mastodon.Preferences
}
notification: {
unread: boolean
latestTime?: Mastodon.Notification['created_at']
}
activeIndex: number | null
instances: InstanceLocal[]
}
remote: {
url: string
}
}
const initialStateLocal: InstancesState['local'] = {
url: undefined,
token: undefined,
account: {
id: undefined,
preferences: {
'posting:default:visibility': undefined,
'posting:default:sensitive': undefined,
'posting:default:language': undefined,
'reading:expand:media': undefined,
'reading:expand:spoilers': undefined
}
},
notification: {
unread: false,
latestTime: undefined
}
}
export const updateLocalAccountPreferences = createAsyncThunk(
'instances/updateLocalAccountPreferences',
async () => {
const { body: preferences } = await client({
export const localUpdateAccountPreferences = createAsyncThunk(
'instances/localUpdateAccountPreferences',
async (): Promise<Mastodon.Preferences> => {
const preferences = await client<Mastodon.Preferences>({
method: 'get',
instance: 'local',
url: `preferences`
})
return preferences as Mastodon.Preferences
return Promise.resolve(preferences)
}
)
export const loginLocal = createAsyncThunk(
'instances/loginLocal',
export const localAddInstance = createAsyncThunk(
'instances/localAddInstance',
async ({
url,
token
token,
appData
}: {
url: InstancesState['local']['url']
token: InstancesState['local']['token']
}) => {
const {
body: { id }
} = await client({
url: InstanceLocal['url']
token: InstanceLocal['token']
appData: InstanceLocal['appData']
}): Promise<InstanceLocal> => {
const store = require('@root/store')
const state = store.getState().instances
const { id } = await client<Mastodon.Account>({
method: 'get',
instance: 'remote',
instanceDomain: url,
@ -72,7 +67,16 @@ export const loginLocal = createAsyncThunk(
headers: { Authorization: `Bearer ${token}` }
})
const { body: preferences } = await client({
// Overwrite existing account?
// if (
// state.local.instances.filter(
// instance => instance && instance.account && instance.account.id === id
// ).length
// ) {
// return Promise.reject()
// }
const preferences = await client<Mastodon.Preferences>({
method: 'get',
instance: 'remote',
instanceDomain: url,
@ -80,60 +84,153 @@ export const loginLocal = createAsyncThunk(
headers: { Authorization: `Bearer ${token}` }
})
return {
return Promise.resolve({
appData,
url,
token,
account: {
id,
preferences
},
notification: {
unread: false
}
} as InstancesState['local']
})
}
)
export const localRemoveInstance = createAsyncThunk(
'instances/localRemoveInstance',
async (index?: InstancesState['local']['activeIndex']): Promise<number> => {
const store = require('@root/store')
const local = store.getState().instances.local
if (index) {
return Promise.resolve(index)
} else {
if (local.activeIndex !== null) {
const currentInstance = local.instances[local.activeIndex]
const revoked = await AuthSession.revokeAsync(
{
clientId: currentInstance.appData.clientId,
clientSecret: currentInstance.appData.clientSecret,
token: currentInstance.token,
scopes: ['read', 'write', 'follow', 'push']
},
{
revocationEndpoint: `https://${currentInstance.url}/oauth/revoke`
}
)
if (!revoked) {
console.warn('Revoking error')
}
return Promise.resolve(local.activeIndex)
} else {
throw new Error('Active index invalid, cannot remove instance')
}
}
}
)
export const instancesInitialState: InstancesState = {
local: {
activeIndex: null,
instances: []
},
remote: {
url: 'm.cmx.im'
}
}
const instancesSlice = createSlice({
name: 'instances',
initialState: {
local: initialStateLocal,
remote: {
url: 'm.cmx.im'
}
} as InstancesState,
initialState: instancesInitialState,
reducers: {
resetLocal: state => {
state.local = initialStateLocal
},
updateNotification: (
localUpdateActiveIndex: (
state,
action: PayloadAction<Partial<InstancesState['local']['notification']>>
action: PayloadAction<NonNullable<InstancesState['local']['activeIndex']>>
) => {
state.local.notification = {
...state.local.notification,
if (action.payload < state.local.instances.length) {
state.local.activeIndex = action.payload
} else {
throw new Error('Set index cannot be found')
}
},
localUpdateNotification: (
state,
action: PayloadAction<Partial<InstanceLocal['notification']>>
) => {
state.local.instances[state.local.activeIndex!].notification = {
...state.local.instances[state.local.activeIndex!].notification,
...action.payload
}
},
remoteUpdate: (
state,
action: PayloadAction<InstancesState['remote']['url']>
) => {
state.remote.url = action.payload
}
},
extraReducers: builder => {
builder
.addCase(loginLocal.fulfilled, (state, action) => {
state.local = action.payload
.addCase(localAddInstance.fulfilled, (state, action) => {
state.local.instances.push(action.payload)
state.local.activeIndex = state.local.instances.length - 1
analytics('login')
})
.addCase(updateLocalAccountPreferences.fulfilled, (state, action) => {
state.local.account.preferences = action.payload
.addCase(localAddInstance.rejected, (state, action) => {
console.error(state.local)
console.error(action.error)
})
.addCase(localRemoveInstance.fulfilled, (state, action) => {
state.local.instances.splice(action.payload, 1)
state.local.activeIndex = state.local.instances.length
? state.local.instances.length - 1
: null
analytics('logout')
})
.addCase(localRemoveInstance.rejected, (state, action) => {
console.error(state.local)
console.error(action.error)
})
.addCase(localUpdateAccountPreferences.fulfilled, (state, action) => {
state.local.instances[state.local.activeIndex!].account.preferences =
action.payload
})
.addCase(localUpdateAccountPreferences.rejected, (_, action) => {
console.error(action.error)
})
}
})
export const getLocalUrl = (state: RootState) => state.instances.local.url
export const getLocalToken = (state: RootState) => state.instances.local.token
export const getLocalNotification = (state: RootState) =>
state.instances.local.notification
export const getRemoteUrl = (state: RootState) => state.instances.remote.url
export const getLocalAccountId = (state: RootState) =>
state.instances.local.account.id
export const getLocalAccountPreferences = (state: RootState) =>
state.instances.local.account.preferences
export const getLocalActiveIndex = ({ instances: { local } }: RootState) =>
local.activeIndex
export const getLocalInstances = ({ instances: { local } }: RootState) =>
local.instances
export const getLocalUrl = ({ instances: { local } }: RootState) =>
local.activeIndex ? local.instances[local.activeIndex].url : undefined
// export const getLocalToken = ({ instances: { local } }: RootState) =>
// local && local.activeIndex && local.instances[local.activeIndex].token
export const getLocalAccount = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].account
: undefined
export const getLocalNotification = ({ instances: { local } }: RootState) =>
local.activeIndex !== null
? local.instances[local.activeIndex].notification
: undefined
export const getRemoteUrl = ({ instances: { remote } }: RootState) => remote.url
export const { resetLocal, updateNotification } = instancesSlice.actions
export const {
localUpdateActiveIndex,
localUpdateNotification,
remoteUpdate
} = instancesSlice.actions
export default instancesSlice.reducer

Some files were not shown because too many files have changed in this diff Show More