* 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
523 lines
13 KiB
TypeScript
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},
|
|
})
|