tooot/src/components/Instance/index.tsx

351 lines
12 KiB
TypeScript

import Button from '@components/Button'
import Icon from '@components/Icon'
import browserPackage from '@helpers/browserPackage'
import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance'
import { checkInstanceFeature, getInstances } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as AuthSession from 'expo-auth-session'
import * as WebBrowser from 'expo-web-browser'
import { debounce } from 'lodash'
import React, { RefObject, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSelector } from 'react-redux'
import { Placeholder } from 'rn-placeholder'
import validUrl from 'valid-url'
import InstanceInfo from './Info'
import CustomText from '../Text'
import { useNavigation } from '@react-navigation/native'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import queryClient from '@helpers/queryClient'
import { useAppDispatch } from '@root/store'
import addInstance from '@utils/slices/instances/add'
export interface Props {
scrollViewRef?: RefObject<ScrollView>
disableHeaderImage?: boolean
goBack?: boolean
}
const ComponentInstance: React.FC<Props> = ({
scrollViewRef,
disableHeaderImage,
goBack = false
}) => {
const { t } = useTranslation(['common', 'componentInstance'])
const { colors, mode } = useTheme()
const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
const [domain, setDomain] = useState<string>('')
const [errorCode, setErrorCode] = useState<number | null>(null)
const whitelisted: boolean =
!!domain.length &&
!!errorCode &&
!!validUrl.isHttpsUri(`https://${domain}`) &&
errorCode === 401
const dispatch = useAppDispatch()
const instances = useSelector(getInstances, () => true)
const instanceQuery = useInstanceQuery({
domain,
options: {
enabled: !!domain,
retry: false,
onError: err => {
if (err.status) {
setErrorCode(err.status)
}
}
}
})
const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
const appsMutation = useAppsMutation({
retry: false,
onSuccess: async (data, variables) => {
const clientId = data.client_id
const clientSecret = data.client_secret
const discovery = { authorizationEndpoint: `https://${domain}/oauth/authorize` }
const request = new AuthSession.AuthRequest({
clientId,
clientSecret,
scopes: deprecateAuthFollow
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push'],
redirectUri
})
await request.makeAuthUrlAsync(discovery)
const promptResult = await request.promptAsync(discovery)
if (promptResult?.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync(
{
clientId,
clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri,
code: promptResult.params.code,
extraParams: { grant_type: 'authorization_code' }
},
{ tokenEndpoint: `https://${variables.domain}/oauth/token` }
)
queryClient.clear()
dispatch(
addInstance({
domain,
token: accessToken,
instance: instanceQuery.data!,
appData: { clientId, clientSecret }
})
)
goBack && navigation.goBack()
}
}
})
const processUpdate = useCallback(() => {
if (domain) {
if (instances && instances.filter(instance => instance.url === domain).length) {
Alert.alert(
t('componentInstance:update.alert.title'),
t('componentInstance:update.alert.message'),
[
{
text: t('common:buttons.cancel'),
style: 'cancel'
},
{
text: t('common:buttons.continue'),
onPress: () => appsMutation.mutate({ domain })
}
]
)
} else {
appsMutation.mutate({ domain })
}
}
}, [domain])
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{!disableHeaderImage ? (
<View style={{ flexDirection: 'row' }}>
<Image
source={require('assets/images/welcome.png')}
style={{ resizeMode: 'contain', flex: 1, aspectRatio: 16 / 9 }}
/>
</View>
) : null}
<View
style={{
marginTop: StyleConstants.Spacing.L,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
>
<View
style={{
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
>
<TextInput
accessible={false}
accessibilityRole='none'
style={{
borderBottomWidth: 1,
...StyleConstants.FontStyle.M,
color: colors.primaryDefault,
borderBottomColor: instanceQuery.isError
? whitelisted
? colors.yellow
: colors.red
: colors.border,
...(Platform.OS === 'android' && { paddingRight: 0 })
}}
editable={false}
defaultValue='https://'
/>
<TextInput
style={{
flex: 1,
borderBottomWidth: 1,
...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.M,
color: colors.primaryDefault,
borderBottomColor: instanceQuery.isError
? whitelisted
? colors.yellow
: colors.red
: colors.border,
...(Platform.OS === 'android' && { paddingLeft: 0 })
}}
onChangeText={debounce(
text => {
setDomain(text.replace(/^http(s)?\:\/\//i, ''))
setErrorCode(null)
},
1000,
{
trailing: true
}
)}
autoCapitalize='none'
clearButtonMode='never'
keyboardType='url'
textContentType='URL'
onSubmitEditing={({ nativeEvent: { text } }) => {
if (
text === domain &&
instanceQuery.isSuccess &&
instanceQuery.data &&
instanceQuery.data.uri
) {
processUpdate()
}
}}
placeholder={' ' + t('componentInstance:server.textInput.placeholder')}
placeholderTextColor={colors.secondary}
returnKeyType='go'
keyboardAppearance={mode}
{...(scrollViewRef && {
onFocus: () =>
setTimeout(() => scrollViewRef.current?.scrollTo({ y: 0, animated: true }), 150)
})}
autoCorrect={false}
spellCheck={false}
/>
<Button
type='text'
content={t('componentInstance:server.button')}
onPress={processUpdate}
disabled={!instanceQuery.data?.uri && !whitelisted}
loading={instanceQuery.isFetching || appsMutation.isLoading}
/>
</View>
<View>
{whitelisted ? (
<CustomText
fontStyle='S'
style={{
color: colors.yellow,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
paddingTop: StyleConstants.Spacing.XS
}}
>
{t('componentInstance:server.whitelisted')}
</CustomText>
) : (
<Placeholder>
<InstanceInfo
header={t('componentInstance:server.information.name')}
content={instanceQuery.data?.title || undefined}
potentialWidth={2}
/>
<View style={{ flex: 1, flexDirection: 'row' }}>
<InstanceInfo
style={{ alignItems: 'flex-start' }}
header={t('componentInstance:server.information.accounts')}
content={instanceQuery.data?.stats?.user_count?.toString() || undefined}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'center' }}
header={t('componentInstance:server.information.statuses')}
content={instanceQuery.data?.stats?.status_count?.toString() || undefined}
potentialWidth={4}
/>
<InstanceInfo
style={{ alignItems: 'flex-end' }}
header={t('componentInstance:server.information.domains')}
content={instanceQuery.data?.stats?.domain_count?.toString() || undefined}
potentialWidth={4}
/>
</View>
</Placeholder>
)}
<View
style={{
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.M
}}
>
<Icon
name='Lock'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={{
marginTop: (StyleConstants.Font.LineHeight.S - StyleConstants.Font.Size.S) / 2,
marginRight: StyleConstants.Spacing.XS
}}
/>
<CustomText fontStyle='S' style={{ flex: 1, color: colors.secondary }}>
{t('componentInstance:server.disclaimer.base')}
</CustomText>
</View>
<View
style={{
flexDirection: 'row',
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.M
}}
>
<Icon
name='CheckSquare'
size={StyleConstants.Font.Size.S}
color={colors.secondary}
style={{
marginTop: (StyleConstants.Font.LineHeight.S - StyleConstants.Font.Size.S) / 2,
marginRight: StyleConstants.Spacing.XS
}}
/>
<CustomText
fontStyle='S'
style={{ flex: 1, color: colors.secondary }}
accessibilityRole='link'
>
<Trans
ns='componentInstance'
i18nKey='server.terms.base'
components={[
<CustomText
accessible
style={{ color: colors.blue }}
onPress={async () =>
WebBrowser.openBrowserAsync('https://tooot.app/privacy-policy', {
...(await browserPackage())
})
}
/>,
<CustomText
accessible
style={{ color: colors.blue }}
onPress={async () =>
WebBrowser.openBrowserAsync('https://tooot.app/terms-of-service', {
...(await browserPackage())
})
}
/>
]}
/>
</CustomText>
</View>
</View>
</View>
</KeyboardAvoidingView>
)
}
export default ComponentInstance