Files
bsky-social-app/src/screens/Settings/components/ChangeHandleDialog.tsx
T
2024-11-20 13:26:23 +00:00

602 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}