[Settings] Thread prefs revamp (#5772)

* thread preferences screen

* minor tweaks

* more spacing

* replace gate with IS_INTERNAL

* [Settings] Following feed prefs revamp (#5773)

* gated new settings screen

* Following feed prefs

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* replace pref following feed gate

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use "Experimental" as the header

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] External media prefs revamp (#5774)

* gated new settings screen

* external media prefs revamp

* replace gate ext media embeds

* Update src/screens/Settings/ExternalMediaPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add imports for translation

* alternate list style on native

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Languages revamp (partial) (#5775)

* language settings (lazy restyle)

* replace gate

* fix text determining flex space

* [Settings] App passwords revamp (#5777)

* rework app passwords screen

* Apply surfdude's copy changes

Thanks @surfdude29!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* format

* replace gate

* use admonition for input error and animate

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Change handle dialog (#5781)

* new change handle dialog

* animations native only

* overflow hidden on togglebutton animation

* add a low-contrast border

* extract out copybutton

* finish change handle dialog

* invalidate query on success

* web fixes

* error message for rate limit exceeded

* typo

* em dash!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* another em dash

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* set maxwidth of suffixtext

* Copy tweak

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Notifs settings revamp (#5884)

* rename, move, and restyle notif settings

* bold "experimental:"

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Samuel Newman
2024-10-31 20:45:34 +00:00
committed by GitHub
parent d85dcc3dd0
commit aa6aad652e
27 changed files with 1991 additions and 128 deletions
@@ -0,0 +1,280 @@
import React, {useEffect, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
SlideInRight,
SlideOutLeft,
} from 'react-native-reanimated'
import {ComAtprotoServerCreateAppPassword} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {isWeb} from '#/platform/detection'
import {useAppPasswordCreateMutation} from '#/state/queries/app-passwords'
import {atoms as a, native, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextInput from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {Text} from '#/components/Typography'
import {CopyButton} from './CopyButton'
export function AddAppPasswordDialog({
control,
passwords,
}: {
control: Dialog.DialogControlProps
passwords: string[]
}) {
const {height} = useWindowDimensions()
return (
<Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
<Dialog.Handle />
<CreateDialogInner passwords={passwords} />
</Dialog.Outer>
)
}
function CreateDialogInner({passwords}: {passwords: string[]}) {
const control = Dialog.useDialogContext()
const t = useTheme()
const {_} = useLingui()
const autogeneratedName = useRandomName()
const [name, setName] = useState('')
const [privileged, setPrivileged] = useState(false)
const {
mutateAsync: actuallyCreateAppPassword,
error: apiError,
data,
} = useAppPasswordCreateMutation()
const regexFailError = useMemo(
() =>
new DisplayableError(
_(
msg`App password names can only contain letters, numbers, spaces, dashes, and underscores`,
),
),
[_],
)
const {
mutate: createAppPassword,
error: validationError,
isPending,
} = useMutation<
ComAtprotoServerCreateAppPassword.AppPassword,
Error | DisplayableError
>({
mutationFn: async () => {
const chosenName = name.trim() || autogeneratedName
if (chosenName.length < 4) {
throw new DisplayableError(
_(msg`App password names must be at least 4 characters long`),
)
}
if (passwords.find(p => p === chosenName)) {
throw new DisplayableError(_(msg`App password name must be unique`))
}
return await actuallyCreateAppPassword({name: chosenName, privileged})
},
})
const [hasBeenCopied, setHasBeenCopied] = useState(false)
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(() => setHasBeenCopied(false), 100)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied])
const error =
validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError)
return (
<Dialog.ScrollableInner label={_(msg`Add app password`)}>
<View style={[native(a.pt_md)]}>
<LayoutAnimationConfig skipEntering skipExiting>
{!data ? (
<Animated.View
style={[a.gap_lg]}
exiting={native(SlideOutLeft)}
key={0}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Add App Password</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Please enter a unique name for this app password or use our
randomly generated one.
</Trans>
</Text>
<View>
<TextInput.Root isInvalid={!!error}>
<Dialog.Input
label={_(msg`App Password`)}
placeholder={autogeneratedName}
onChangeText={setName}
returnKeyType="done"
onSubmitEditing={() => createAppPassword()}
blurOnSubmit
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextInput.Root>
</View>
{error instanceof DisplayableError && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Admonition type="error">{error.message}</Admonition>
</Animated.View>
)}
<Animated.View
style={[a.gap_lg]}
layout={native(LinearTransition)}>
<Toggle.Item
name="privileged"
type="checkbox"
label={_(msg`Allow access to your direct messages`)}
value={privileged}
onChange={setPrivileged}
style={[a.flex_1]}>
<Toggle.Checkbox />
<Toggle.LabelText
style={[a.font_normal, a.text_md, a.leading_snug]}>
<Trans>Allow access to your direct messages</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Button
label={_(msg`Next`)}
size="large"
variant="solid"
color="primary"
style={[a.flex_1]}
onPress={() => createAppPassword()}
disabled={isPending}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} />
</Button>
{!!apiError ||
(error && !(error instanceof DisplayableError) && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Admonition type="error">
<Trans>
Failed to create app password. Please try again.
</Trans>
</Admonition>
</Animated.View>
))}
</Animated.View>
</Animated.View>
) : (
<Animated.View
style={[a.gap_lg]}
entering={isWeb ? FadeIn.delay(200) : SlideInRight}
key={1}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Here is your app password!</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Use this to sign into the other app along with your handle.
</Trans>
</Text>
<CopyButton
value={data.password}
label={_(msg`Copy App Password`)}
size="large"
variant="solid"
color="secondary">
<ButtonText>{data.password}</ButtonText>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
For security reasons, you won't be able to view this again. If
you lose this app password, you'll need to generate a new one.
</Trans>
</Text>
<Button
label={_(msg`Done`)}
size="large"
variant="outline"
color="primary"
style={[a.flex_1]}
onPress={() => control.close()}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Animated.View>
)}
</LayoutAnimationConfig>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
class DisplayableError extends Error {
constructor(message: string) {
super(message)
this.name = 'DisplayableError'
}
}
function useRandomName() {
return useState(
() => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
)[0]
}
const shadesOfBlue: string[] = [
'AliceBlue',
'Aqua',
'Aquamarine',
'Azure',
'BabyBlue',
'Blue',
'BlueViolet',
'CadetBlue',
'CornflowerBlue',
'Cyan',
'DarkBlue',
'DarkCyan',
'DarkSlateBlue',
'DeepSkyBlue',
'DodgerBlue',
'ElectricBlue',
'LightBlue',
'LightCyan',
'LightSkyBlue',
'LightSteelBlue',
'MediumAquaMarine',
'MediumBlue',
'MediumSlateBlue',
'MidnightBlue',
'Navy',
'PowderBlue',
'RoyalBlue',
'SkyBlue',
'SlateBlue',
'SteelBlue',
'Teal',
'Turquoise',
]
@@ -0,0 +1,602 @@
import React, {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`)}
style={[a.overflow_hidden]}
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>
)
}
@@ -0,0 +1,69 @@
import React, {useCallback, useEffect, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {Text} from '#/components/Typography'
export function CopyButton({
style,
value,
onPress: onPressProp,
...props
}: ButtonProps & {value: string}) {
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const t = useTheme()
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(() => setHasBeenCopied(false), 100)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied])
const onPress = useCallback(
(evt: GestureResponderEvent) => {
Clipboard.setStringAsync(value)
setHasBeenCopied(true)
onPressProp?.(evt)
},
[value, onPressProp],
)
return (
<View style={[a.relative]}>
{hasBeenCopied && (
<Animated.View
entering={ZoomIn.duration(100)}
exiting={FadeOutUp.duration(2000)}
style={[
a.absolute,
{bottom: '100%', right: 0},
a.justify_center,
a.gap_sm,
a.z_10,
a.pb_sm,
]}
pointerEvents="none">
<Text
style={[
a.font_bold,
a.text_right,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Copied!</Trans>
</Text>
</Animated.View>
)}
<Button
style={[a.flex_1, a.justify_between, style]}
onPress={onPress}
{...props}
/>
</View>
)
}
@@ -2,7 +2,7 @@ import React, {useContext, useMemo} from 'react'
import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import * as Button from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Link, LinkProps} from '#/components/Link'
@@ -17,7 +17,7 @@ const ItemContext = React.createContext({
const Portal = createPortalGroup()
export function Container({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.py_lg]}>{children}</View>
return <View style={[a.flex_1, a.py_md]}>{children}</View>
}
/**
@@ -241,11 +241,17 @@ export function ItemText({
}
}
export function Divider() {
export function Divider({style}: ViewStyleProp) {
const t = useTheme()
return (
<View
style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]}
style={[
a.border_t,
t.atoms.border_contrast_medium,
a.w_full,
a.my_sm,
style,
]}
/>
)
}