602 lines
19 KiB
TypeScript
602 lines
19 KiB
TypeScript
import {useCallback, useMemo, useState} from 'react'
|
||
import {useWindowDimensions, View} from 'react-native'
|
||
import Animated, {
|
||
FadeIn,
|
||
FadeOut,
|
||
LayoutAnimationConfig,
|
||
LinearTransition,
|
||
SlideInLeft,
|
||
SlideInRight,
|
||
SlideOutLeft,
|
||
SlideOutRight,
|
||
} from 'react-native-reanimated'
|
||
import {ComAtprotoServerDescribeServer} from '@atproto/api'
|
||
import {msg, Trans} from '@lingui/macro'
|
||
import {useLingui} from '@lingui/react'
|
||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||
|
||
import {HITSLOP_10} from '#/lib/constants'
|
||
import {cleanError} from '#/lib/strings/errors'
|
||
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
|
||
import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
|
||
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
||
import {useServiceQuery} from '#/state/queries/service'
|
||
import {useAgent, useSession} from '#/state/session'
|
||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||
import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
|
||
import {Admonition} from '#/components/Admonition'
|
||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||
import * as Dialog from '#/components/Dialog'
|
||
import * as TextField from '#/components/forms/TextField'
|
||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
|
||
import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
|
||
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||
import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
|
||
import {InlineLinkText} from '#/components/Link'
|
||
import {Loader} from '#/components/Loader'
|
||
import {Text} from '#/components/Typography'
|
||
import {CopyButton} from './CopyButton'
|
||
|
||
export function ChangeHandleDialog({
|
||
control,
|
||
}: {
|
||
control: Dialog.DialogControlProps
|
||
}) {
|
||
const {height} = useWindowDimensions()
|
||
|
||
return (
|
||
<Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
|
||
<ChangeHandleDialogInner />
|
||
</Dialog.Outer>
|
||
)
|
||
}
|
||
|
||
function ChangeHandleDialogInner() {
|
||
const control = Dialog.useDialogContext()
|
||
const {_} = useLingui()
|
||
const agent = useAgent()
|
||
const {
|
||
data: serviceInfo,
|
||
error: serviceInfoError,
|
||
refetch,
|
||
} = useServiceQuery(agent.serviceUrl.toString())
|
||
|
||
const [page, setPage] = useState<'provided-handle' | 'own-handle'>(
|
||
'provided-handle',
|
||
)
|
||
|
||
const cancelButton = useCallback(
|
||
() => (
|
||
<Button
|
||
label={_(msg`Cancel`)}
|
||
onPress={() => control.close()}
|
||
size="small"
|
||
color="primary"
|
||
variant="ghost"
|
||
style={[a.rounded_full]}>
|
||
<ButtonText style={[a.text_md]}>
|
||
<Trans>Cancel</Trans>
|
||
</ButtonText>
|
||
</Button>
|
||
),
|
||
[control, _],
|
||
)
|
||
|
||
return (
|
||
<Dialog.ScrollableInner
|
||
label={_(msg`Change Handle`)}
|
||
header={
|
||
<Dialog.Header renderLeft={cancelButton}>
|
||
<Dialog.HeaderText>
|
||
<Trans>Change Handle</Trans>
|
||
</Dialog.HeaderText>
|
||
</Dialog.Header>
|
||
}
|
||
contentContainerStyle={[a.pt_0, a.px_0]}>
|
||
<View style={[a.flex_1, a.pt_lg, a.px_xl]}>
|
||
{serviceInfoError ? (
|
||
<ErrorScreen
|
||
title={_(msg`Oops!`)}
|
||
message={_(msg`There was an issue fetching your service info`)}
|
||
details={cleanError(serviceInfoError)}
|
||
onPressTryAgain={refetch}
|
||
/>
|
||
) : serviceInfo ? (
|
||
<LayoutAnimationConfig skipEntering skipExiting>
|
||
{page === 'provided-handle' ? (
|
||
<Animated.View
|
||
key={page}
|
||
entering={native(SlideInLeft)}
|
||
exiting={native(SlideOutLeft)}>
|
||
<ProvidedHandlePage
|
||
serviceInfo={serviceInfo}
|
||
goToOwnHandle={() => setPage('own-handle')}
|
||
/>
|
||
</Animated.View>
|
||
) : (
|
||
<Animated.View
|
||
key={page}
|
||
entering={native(SlideInRight)}
|
||
exiting={native(SlideOutRight)}>
|
||
<OwnHandlePage
|
||
goToServiceHandle={() => setPage('provided-handle')}
|
||
/>
|
||
</Animated.View>
|
||
)}
|
||
</LayoutAnimationConfig>
|
||
) : (
|
||
<View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}>
|
||
<Loader size="xl" />
|
||
</View>
|
||
)}
|
||
</View>
|
||
</Dialog.ScrollableInner>
|
||
)
|
||
}
|
||
|
||
function ProvidedHandlePage({
|
||
serviceInfo,
|
||
goToOwnHandle,
|
||
}: {
|
||
serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
|
||
goToOwnHandle: () => void
|
||
}) {
|
||
const {_} = useLingui()
|
||
const [subdomain, setSubdomain] = useState('')
|
||
const agent = useAgent()
|
||
const control = Dialog.useDialogContext()
|
||
const {currentAccount} = useSession()
|
||
const queryClient = useQueryClient()
|
||
|
||
const {
|
||
mutate: changeHandle,
|
||
isPending,
|
||
error,
|
||
isSuccess,
|
||
} = useUpdateHandleMutation({
|
||
onSuccess: () => {
|
||
if (currentAccount) {
|
||
queryClient.invalidateQueries({
|
||
queryKey: RQKEY_PROFILE(currentAccount.did),
|
||
})
|
||
}
|
||
agent.resumeSession(agent.session!).then(() => control.close())
|
||
},
|
||
})
|
||
|
||
const host = serviceInfo.availableUserDomains[0]
|
||
|
||
const validation = useMemo(
|
||
() => validateHandle(subdomain, host),
|
||
[subdomain, host],
|
||
)
|
||
|
||
const isTooLong = subdomain.length > 18
|
||
const isInvalid =
|
||
isTooLong ||
|
||
!validation.handleChars ||
|
||
!validation.hyphenStartOrEnd ||
|
||
!validation.totalLength
|
||
|
||
return (
|
||
<LayoutAnimationConfig skipEntering>
|
||
<View style={[a.flex_1, a.gap_md]}>
|
||
{isSuccess && (
|
||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||
<SuccessMessage text={_(msg`Handle changed!`)} />
|
||
</Animated.View>
|
||
)}
|
||
{error && (
|
||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||
<ChangeHandleError error={error} />
|
||
</Animated.View>
|
||
)}
|
||
<Animated.View
|
||
layout={native(LinearTransition)}
|
||
style={[a.flex_1, a.gap_md]}>
|
||
<View>
|
||
<TextField.LabelText>
|
||
<Trans>New handle</Trans>
|
||
</TextField.LabelText>
|
||
<TextField.Root isInvalid={isInvalid}>
|
||
<TextField.Icon icon={AtIcon} />
|
||
<Dialog.Input
|
||
editable={!isPending}
|
||
defaultValue={subdomain}
|
||
onChangeText={text => setSubdomain(text)}
|
||
label={_(msg`New handle`)}
|
||
placeholder={_(msg`e.g. alice`)}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
<TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}>
|
||
{host}
|
||
</TextField.SuffixText>
|
||
</TextField.Root>
|
||
</View>
|
||
<Text>
|
||
<Trans>
|
||
Your full handle will be{' '}
|
||
<Text style={[a.font_bold]}>
|
||
@{createFullHandle(subdomain, host)}
|
||
</Text>
|
||
</Trans>
|
||
</Text>
|
||
<Button
|
||
label={_(msg`Save new handle`)}
|
||
variant="solid"
|
||
size="large"
|
||
color={validation.overall && !isTooLong ? 'primary' : 'secondary'}
|
||
disabled={!validation.overall && !isTooLong}
|
||
onPress={() => {
|
||
if (validation.overall && !isTooLong) {
|
||
changeHandle({handle: createFullHandle(subdomain, host)})
|
||
}
|
||
}}>
|
||
{isPending ? (
|
||
<ButtonIcon icon={Loader} />
|
||
) : (
|
||
<ButtonText>
|
||
<Trans>Save</Trans>
|
||
</ButtonText>
|
||
)}
|
||
</Button>
|
||
<Text style={[a.leading_snug]}>
|
||
<Trans>
|
||
If you have your own domain, you can use that as your handle. This
|
||
lets you self-verify your identity –{' '}
|
||
<InlineLinkText
|
||
label={_(msg`learn more`)}
|
||
to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
|
||
style={[a.font_bold]}
|
||
disableMismatchWarning>
|
||
learn more
|
||
</InlineLinkText>
|
||
.
|
||
</Trans>
|
||
</Text>
|
||
<Button
|
||
label={_(msg`I have my own domain`)}
|
||
variant="outline"
|
||
color="primary"
|
||
size="large"
|
||
onPress={goToOwnHandle}>
|
||
<ButtonText>
|
||
<Trans>I have my own domain</Trans>
|
||
</ButtonText>
|
||
<ButtonIcon icon={ArrowRightIcon} />
|
||
</Button>
|
||
</Animated.View>
|
||
</View>
|
||
</LayoutAnimationConfig>
|
||
)
|
||
}
|
||
|
||
function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
|
||
const {_} = useLingui()
|
||
const t = useTheme()
|
||
const {currentAccount} = useSession()
|
||
const [dnsPanel, setDNSPanel] = useState(true)
|
||
const [domain, setDomain] = useState('')
|
||
const agent = useAgent()
|
||
const control = Dialog.useDialogContext()
|
||
const fetchDid = useFetchDid()
|
||
const queryClient = useQueryClient()
|
||
|
||
const {
|
||
mutate: changeHandle,
|
||
isPending,
|
||
error,
|
||
isSuccess,
|
||
} = useUpdateHandleMutation({
|
||
onSuccess: () => {
|
||
if (currentAccount) {
|
||
queryClient.invalidateQueries({
|
||
queryKey: RQKEY_PROFILE(currentAccount.did),
|
||
})
|
||
}
|
||
agent.resumeSession(agent.session!).then(() => control.close())
|
||
},
|
||
})
|
||
|
||
const {
|
||
mutate: verify,
|
||
isPending: isVerifyPending,
|
||
isSuccess: isVerified,
|
||
error: verifyError,
|
||
reset: resetVerification,
|
||
} = useMutation<true, Error | DidMismatchError>({
|
||
mutationKey: ['verify-handle', domain],
|
||
mutationFn: async () => {
|
||
const did = await fetchDid(domain)
|
||
if (did !== currentAccount?.did) {
|
||
throw new DidMismatchError(did)
|
||
}
|
||
return true
|
||
},
|
||
})
|
||
|
||
return (
|
||
<View style={[a.flex_1, a.gap_lg]}>
|
||
{isSuccess && (
|
||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||
<SuccessMessage text={_(msg`Handle changed!`)} />
|
||
</Animated.View>
|
||
)}
|
||
{error && (
|
||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||
<ChangeHandleError error={error} />
|
||
</Animated.View>
|
||
)}
|
||
{verifyError && (
|
||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||
<Admonition type="error">
|
||
{verifyError instanceof DidMismatchError ? (
|
||
<Trans>
|
||
Wrong DID returned from server. Received: {verifyError.did}
|
||
</Trans>
|
||
) : (
|
||
<Trans>Failed to verify handle. Please try again.</Trans>
|
||
)}
|
||
</Admonition>
|
||
</Animated.View>
|
||
)}
|
||
<Animated.View
|
||
layout={native(LinearTransition)}
|
||
style={[a.flex_1, a.gap_md, a.overflow_hidden]}>
|
||
<View>
|
||
<TextField.LabelText>
|
||
<Trans>Enter the domain you want to use</Trans>
|
||
</TextField.LabelText>
|
||
<TextField.Root>
|
||
<TextField.Icon icon={AtIcon} />
|
||
<Dialog.Input
|
||
label={_(msg`New handle`)}
|
||
placeholder={_(msg`e.g. alice.com`)}
|
||
editable={!isPending}
|
||
defaultValue={domain}
|
||
onChangeText={text => {
|
||
setDomain(text)
|
||
resetVerification()
|
||
}}
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
</TextField.Root>
|
||
</View>
|
||
<ToggleButton.Group
|
||
label={_(msg`Choose domain verification method`)}
|
||
values={[dnsPanel ? 'dns' : 'file']}
|
||
onChange={values => setDNSPanel(values[0] === 'dns')}>
|
||
<ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}>
|
||
<ToggleButton.ButtonText>
|
||
<Trans>DNS Panel</Trans>
|
||
</ToggleButton.ButtonText>
|
||
</ToggleButton.Button>
|
||
<ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}>
|
||
<ToggleButton.ButtonText>
|
||
<Trans>No DNS Panel</Trans>
|
||
</ToggleButton.ButtonText>
|
||
</ToggleButton.Button>
|
||
</ToggleButton.Group>
|
||
{dnsPanel ? (
|
||
<>
|
||
<Text>
|
||
<Trans>Add the following DNS record to your domain:</Trans>
|
||
</Text>
|
||
<View
|
||
style={[
|
||
t.atoms.bg_contrast_25,
|
||
a.rounded_sm,
|
||
a.p_md,
|
||
a.border,
|
||
t.atoms.border_contrast_low,
|
||
]}>
|
||
<Text style={[t.atoms.text_contrast_medium]}>
|
||
<Trans>Host:</Trans>
|
||
</Text>
|
||
<View style={[a.py_xs]}>
|
||
<CopyButton
|
||
variant="solid"
|
||
color="secondary"
|
||
value="_atproto"
|
||
label={_(msg`Copy host`)}
|
||
hoverStyle={[a.bg_transparent]}
|
||
hitSlop={HITSLOP_10}>
|
||
<Text style={[a.text_md, a.flex_1]}>_atproto</Text>
|
||
<ButtonIcon icon={CopyIcon} />
|
||
</CopyButton>
|
||
</View>
|
||
<Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
|
||
<Trans>Type:</Trans>
|
||
</Text>
|
||
<View style={[a.py_xs]}>
|
||
<Text style={[a.text_md]}>TXT</Text>
|
||
</View>
|
||
<Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
|
||
<Trans>Value:</Trans>
|
||
</Text>
|
||
<View style={[a.py_xs]}>
|
||
<CopyButton
|
||
variant="solid"
|
||
color="secondary"
|
||
value={'did=' + currentAccount?.did}
|
||
label={_(msg`Copy TXT record value`)}
|
||
hoverStyle={[a.bg_transparent]}
|
||
hitSlop={HITSLOP_10}>
|
||
<Text style={[a.text_md, a.flex_1]}>
|
||
did={currentAccount?.did}
|
||
</Text>
|
||
<ButtonIcon icon={CopyIcon} />
|
||
</CopyButton>
|
||
</View>
|
||
</View>
|
||
<Text>
|
||
<Trans>This should create a domain record at:</Trans>
|
||
</Text>
|
||
<View
|
||
style={[
|
||
t.atoms.bg_contrast_25,
|
||
a.rounded_sm,
|
||
a.p_md,
|
||
a.border,
|
||
t.atoms.border_contrast_low,
|
||
]}>
|
||
<Text style={[a.text_md]}>_atproto.{domain}</Text>
|
||
</View>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Text>
|
||
<Trans>Upload a text file to:</Trans>
|
||
</Text>
|
||
<View
|
||
style={[
|
||
t.atoms.bg_contrast_25,
|
||
a.rounded_sm,
|
||
a.p_md,
|
||
a.border,
|
||
t.atoms.border_contrast_low,
|
||
]}>
|
||
<Text style={[a.text_md]}>
|
||
https://{domain}/.well-known/atproto-did
|
||
</Text>
|
||
</View>
|
||
<Text>
|
||
<Trans>That contains the following:</Trans>
|
||
</Text>
|
||
<CopyButton
|
||
value={currentAccount?.did ?? ''}
|
||
label={_(msg`Copy DID`)}
|
||
size="large"
|
||
variant="solid"
|
||
color="secondary"
|
||
style={[a.px_md, a.border, t.atoms.border_contrast_low]}>
|
||
<Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text>
|
||
<ButtonIcon icon={CopyIcon} />
|
||
</CopyButton>
|
||
</>
|
||
)}
|
||
</Animated.View>
|
||
{isVerified && (
|
||
<Animated.View
|
||
entering={FadeIn}
|
||
exiting={FadeOut}
|
||
layout={native(LinearTransition)}>
|
||
<SuccessMessage text={_(msg`Domain verified!`)} />
|
||
</Animated.View>
|
||
)}
|
||
<Animated.View layout={native(LinearTransition)}>
|
||
<Button
|
||
label={
|
||
isVerified
|
||
? _(msg`Update to ${domain}`)
|
||
: dnsPanel
|
||
? _(msg`Verify DNS Record`)
|
||
: _(msg`Verify Text File`)
|
||
}
|
||
variant="solid"
|
||
size="large"
|
||
color={domain.trim().length > 0 ? 'primary' : 'secondary'}
|
||
disabled={domain.trim().length === 0}
|
||
onPress={() => {
|
||
if (isVerified) {
|
||
changeHandle({handle: domain})
|
||
} else {
|
||
verify()
|
||
}
|
||
}}>
|
||
{isPending || isVerifyPending ? (
|
||
<ButtonIcon icon={Loader} />
|
||
) : (
|
||
<ButtonText>
|
||
{isVerified ? (
|
||
<Trans>Update to {domain}</Trans>
|
||
) : dnsPanel ? (
|
||
<Trans>Verify DNS Record</Trans>
|
||
) : (
|
||
<Trans>Verify Text File</Trans>
|
||
)}
|
||
</ButtonText>
|
||
)}
|
||
</Button>
|
||
</Animated.View>
|
||
<Animated.View layout={native(LinearTransition)}>
|
||
<Button
|
||
label={_(msg`Use default provider`)}
|
||
accessibilityHint={_(msg`Go back to previous page`)}
|
||
onPress={goToServiceHandle}
|
||
style={[a.p_0, a.justify_start]}>
|
||
<ButtonText style={[{color: t.palette.primary_500}, a.text_left]}>
|
||
<Trans>Nevermind, create a handle for me</Trans>
|
||
</ButtonText>
|
||
</Button>
|
||
</Animated.View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
class DidMismatchError extends Error {
|
||
did: string
|
||
constructor(did: string) {
|
||
super('DID mismatch')
|
||
this.name = 'DidMismatchError'
|
||
this.did = did
|
||
}
|
||
}
|
||
|
||
function ChangeHandleError({error}: {error: unknown}) {
|
||
const {_} = useLingui()
|
||
|
||
let message = _(msg`Failed to change handle. Please try again.`)
|
||
|
||
if (error instanceof Error) {
|
||
if (error.message.startsWith('Handle already taken')) {
|
||
message = _(msg`Handle already taken. Please try a different one.`)
|
||
} else if (error.message === 'Reserved handle') {
|
||
message = _(msg`This handle is reserved. Please try a different one.`)
|
||
} else if (error.message === 'Handle too long') {
|
||
message = _(msg`Handle too long. Please try a shorter one.`)
|
||
} else if (error.message === 'Input/handle must be a valid handle') {
|
||
message = _(msg`Invalid handle. Please try a different one.`)
|
||
} else if (error.message === 'Rate Limit Exceeded') {
|
||
message = _(
|
||
msg`Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again.`,
|
||
)
|
||
}
|
||
}
|
||
|
||
return <Admonition type="error">{message}</Admonition>
|
||
}
|
||
|
||
function SuccessMessage({text}: {text: string}) {
|
||
const {gtMobile} = useBreakpoints()
|
||
const t = useTheme()
|
||
return (
|
||
<View
|
||
style={[
|
||
a.flex_1,
|
||
a.gap_md,
|
||
a.flex_row,
|
||
a.justify_center,
|
||
a.align_center,
|
||
gtMobile ? a.px_md : a.px_sm,
|
||
a.py_xs,
|
||
t.atoms.border_contrast_low,
|
||
]}>
|
||
<View
|
||
style={[
|
||
{height: 20, width: 20},
|
||
a.rounded_full,
|
||
a.align_center,
|
||
a.justify_center,
|
||
{backgroundColor: t.palette.positive_600},
|
||
]}>
|
||
<CheckIcon fill={t.palette.white} size="xs" />
|
||
</View>
|
||
<Text style={[a.text_md]}>{text}</Text>
|
||
</View>
|
||
)
|
||
}
|