bsky-app/src/view/com/modals/ChangeHandle.tsx
Paul Frazee a3334a01a2 Lex refactor (#362)
* Remove the hackcheck for upgrades

* Rename the PostEmbeds folder to match the codebase style

* Updates to latest lex refactor

* Update to use new bsky agent

* Update to use api package's richtext library

* Switch to upsertProfile

* Add TextEncoder/TextDecoder polyfill

* Add Intl.Segmenter polyfill

* Update composer to calculate lengths by grapheme

* Fix detox

* Fix login in e2e

* Create account e2e passing

* Implement an e2e mocking framework

* Don't use private methods on mobx models as mobx can't track them

* Add tooling for e2e-specific builds and add e2e media-picker mock

* Add some tests and fix some bugs around profile editing

* Add shell tests

* Add home screen tests

* Add thread screen tests

* Add tests for other user profile screens

* Add search screen tests

* Implement profile imagery change tools and tests

* Update to new embed behaviors

* Add post tests

* Fix to profile-screen test

* Fix session resumption

* Update web composer to new api

* 1.11.0

* Fix pagination cursor parameters

* Add quote posts to notifications

* Fix embed layouts

* Remove youtube inline player and improve tap handling on link cards

* Reset minimal shell mode on all screen loads and feed swipes (close #299)

* Update podfile.lock

* Improve post notfound UI (close #366)

* Bump atproto packages
2023-03-31 13:17:26 -05:00

523 lines
13 KiB
TypeScript

import React, {useState} from 'react'
import Clipboard from '@react-native-clipboard/clipboard'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {s} from 'lib/styles'
import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useAnalytics} from 'lib/analytics'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['100%']
export function Component({onChanged}: {onChanged: () => void}) {
const store = useStores()
const [error, setError] = useState<string>('')
const pal = usePalette('default')
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>(
{},
)
const [serviceDescription, setServiceDescription] = React.useState<
ServiceDescription | undefined
>(undefined)
const [userDomain, setUserDomain] = React.useState<string>('')
const [isCustom, setCustom] = React.useState<boolean>(false)
const [handle, setHandle] = React.useState<string>('')
const [canSave, setCanSave] = React.useState<boolean>(false)
// init
// =
React.useEffect(() => {
let aborted = false
setError('')
setServiceDescription(undefined)
setProcessing(true)
// load the service description so we can properly provision handles
store.session.describeService(String(store.agent.service)).then(
desc => {
if (aborted) {
return
}
setServiceDescription(desc)
setUserDomain(desc.availableUserDomains[0])
setProcessing(false)
},
err => {
if (aborted) {
return
}
setProcessing(false)
store.log.warn(
`Failed to fetch service description for ${String(
store.agent.service,
)}`,
err,
)
setError(
'Unable to contact your service. Please check your Internet connection.',
)
},
)
return () => {
aborted = true
}
}, [store.agent.service, store.session, store.log, retryDescribeTrigger])
// events
// =
const onPressCancel = React.useCallback(() => {
store.shell.closeModal()
}, [store])
const onPressRetryConnect = React.useCallback(
() => setRetryDescribeTrigger({}),
[setRetryDescribeTrigger],
)
const onToggleCustom = React.useCallback(() => {
// toggle between a provided domain vs a custom one
setHandle('')
setCanSave(false)
setCustom(!isCustom)
track(
isCustom ? 'EditHandle:ViewCustomForm' : 'EditHandle:ViewProvidedForm',
)
}, [setCustom, isCustom, track])
const onPressSave = React.useCallback(async () => {
setError('')
setProcessing(true)
try {
track('EditHandle:SetNewHandle')
const newHandle = isCustom ? handle : createFullHandle(handle, userDomain)
store.log.debug(`Updating handle to ${newHandle}`)
await store.agent.updateHandle({
handle: newHandle,
})
store.shell.closeModal()
onChanged()
} catch (err: any) {
setError(cleanError(err))
store.log.error('Failed to update handle', {handle, err})
} finally {
setProcessing(false)
}
}, [
setError,
setProcessing,
handle,
userDomain,
store,
isCustom,
onChanged,
track,
])
// rendering
// =
return (
<View style={[s.flex1, pal.view]}>
<View style={[styles.title, pal.border]}>
<View style={styles.titleLeft}>
<TouchableOpacity onPress={onPressCancel}>
<Text type="lg" style={pal.textLight}>
Cancel
</Text>
</TouchableOpacity>
</View>
<Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}>
Change my handle
</Text>
<View style={styles.titleRight}>
{isProcessing ? (
<ActivityIndicator />
) : error && !serviceDescription ? (
<TouchableOpacity
testID="retryConnectButton"
onPress={onPressRetryConnect}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
Retry
</Text>
</TouchableOpacity>
) : canSave ? (
<TouchableOpacity onPress={onPressSave}>
<Text type="2xl-medium" style={pal.link}>
Save
</Text>
</TouchableOpacity>
) : undefined}
</View>
</View>
<ScrollView style={styles.inner}>
{error !== '' && (
<View style={styles.errorContainer}>
<ErrorMessage message={error} />
</View>
)}
{isCustom ? (
<CustomHandleForm
handle={handle}
isProcessing={isProcessing}
canSave={canSave}
onToggleCustom={onToggleCustom}
setHandle={setHandle}
setCanSave={setCanSave}
onPressSave={onPressSave}
/>
) : (
<ProvidedHandleForm
handle={handle}
userDomain={userDomain}
isProcessing={isProcessing}
onToggleCustom={onToggleCustom}
setHandle={setHandle}
setCanSave={setCanSave}
/>
)}
</ScrollView>
</View>
)
}
/**
* The form for using a domain allocated by the PDS
*/
function ProvidedHandleForm({
userDomain,
handle,
isProcessing,
setHandle,
onToggleCustom,
setCanSave,
}: {
userDomain: string
handle: string
isProcessing: boolean
setHandle: (v: string) => void
onToggleCustom: () => void
setCanSave: (v: boolean) => void
}) {
const pal = usePalette('default')
const theme = useTheme()
// events
// =
const onChangeHandle = React.useCallback(
(v: string) => {
const newHandle = makeValidHandle(v)
setHandle(newHandle)
setCanSave(newHandle.length > 0)
},
[setHandle, setCanSave],
)
// rendering
// =
return (
<>
<View style={[pal.btn, styles.textInputWrapper]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.textInputIcon]}
/>
<TextInput
testID="setHandleInput"
style={[pal.text, styles.textInput]}
placeholder="eg alice"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
keyboardAppearance={theme.colorScheme}
value={handle}
onChangeText={onChangeHandle}
editable={!isProcessing}
/>
</View>
<Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
Your full handle will be{' '}
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
</Text>
<TouchableOpacity onPress={onToggleCustom}>
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
I have my own domain
</Text>
</TouchableOpacity>
</>
)
}
/**
* The form for using a custom domain
*/
function CustomHandleForm({
handle,
canSave,
isProcessing,
setHandle,
onToggleCustom,
onPressSave,
setCanSave,
}: {
handle: string
canSave: boolean
isProcessing: boolean
setHandle: (v: string) => void
onToggleCustom: () => void
onPressSave: () => void
setCanSave: (v: boolean) => void
}) {
const store = useStores()
const pal = usePalette('default')
const palSecondary = usePalette('secondary')
const palError = usePalette('error')
const theme = useTheme()
const [isVerifying, setIsVerifying] = React.useState(false)
const [error, setError] = React.useState<string>('')
// events
// =
const onPressCopy = React.useCallback(() => {
Clipboard.setString(`did=${store.me.did}`)
Toast.show('Copied to clipboard')
}, [store.me.did])
const onChangeHandle = React.useCallback(
(v: string) => {
setHandle(v)
setCanSave(false)
},
[setHandle, setCanSave],
)
const onPressVerify = React.useCallback(async () => {
if (canSave) {
onPressSave()
}
try {
setIsVerifying(true)
setError('')
const res = await store.agent.com.atproto.identity.resolveHandle({handle})
if (res.data.did === store.me.did) {
setCanSave(true)
} else {
setError(`Incorrect DID returned (got ${res.data.did})`)
}
} catch (err: any) {
setError(cleanError(err))
store.log.error('Failed to verify domain', {handle, err})
} finally {
setIsVerifying(false)
}
}, [
handle,
store.me.did,
setIsVerifying,
setCanSave,
setError,
canSave,
onPressSave,
store.log,
store.agent,
])
// rendering
// =
return (
<>
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
Enter the domain you want to use
</Text>
<View style={[pal.btn, styles.textInputWrapper]}>
<FontAwesomeIcon
icon="at"
style={[pal.textLight, styles.textInputIcon]}
/>
<TextInput
testID="setHandleInput"
style={[pal.text, styles.textInput]}
placeholder="eg alice.com"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
keyboardAppearance={theme.colorScheme}
value={handle}
onChangeText={onChangeHandle}
editable={!isProcessing}
/>
</View>
<View style={styles.spacer} />
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
Add the following record to your domain:
</Text>
<View style={[styles.dnsTable, pal.btn]}>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
Domain:
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
_atproto.{handle}
</Text>
</View>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
Type:
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
TXT
</Text>
</View>
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
Value:
</Text>
<View style={[styles.dnsValue]}>
<Text type="mono" style={[styles.monoText, pal.text]}>
did={store.me.did}
</Text>
</View>
</View>
<View style={styles.spacer} />
<Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}>
<Text type="xl" style={[pal.link, s.textCenter]}>
Copy Domain Value
</Text>
</Button>
{canSave === true && (
<View style={[styles.message, palSecondary.view]}>
<Text type="md-medium" style={palSecondary.text}>
Domain verified!
</Text>
</View>
)}
{error && (
<View style={[styles.message, palError.view]}>
<Text type="md-medium" style={palError.text}>
{error}
</Text>
</View>
)}
<Button
type="primary"
style={[s.p20, isVerifying && styles.dimmed]}
onPress={onPressVerify}>
{isVerifying ? (
<ActivityIndicator color="white" />
) : (
<Text type="xl-medium" style={[s.white, s.textCenter]}>
{canSave ? `Update to ${handle}` : 'Verify DNS Record'}
</Text>
)}
</Button>
<View style={styles.spacer} />
<TouchableOpacity onPress={onToggleCustom}>
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
Nevermind, create a handle for me
</Text>
</TouchableOpacity>
</>
)
}
const styles = StyleSheet.create({
inner: {
padding: 14,
},
footer: {
padding: 14,
},
spacer: {
height: 20,
},
dimmed: {
opacity: 0.7,
},
title: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 25,
paddingHorizontal: 20,
paddingBottom: 15,
borderBottomWidth: 1,
},
titleLeft: {
width: 80,
},
titleRight: {
width: 80,
flexDirection: 'row',
justifyContent: 'flex-end',
},
titleMiddle: {
flex: 1,
textAlign: 'center',
fontSize: 21,
},
textInputWrapper: {
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
},
textInputIcon: {
marginLeft: 12,
},
textInput: {
flex: 1,
width: '100%',
paddingVertical: 10,
paddingHorizontal: 8,
fontSize: 17,
letterSpacing: 0.25,
fontWeight: '400',
borderRadius: 10,
},
dnsTable: {
borderRadius: 4,
paddingTop: 2,
paddingBottom: 16,
},
dnsLabel: {
paddingHorizontal: 14,
paddingTop: 10,
},
dnsValue: {
paddingHorizontal: 14,
borderRadius: 4,
},
monoText: {
fontSize: 18,
lineHeight: 20,
},
message: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 8,
marginBottom: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 10,
marginBottom: 10,
},
errorContainer: {marginBottom: 10},
})