[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:
@@ -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,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user