[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,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>
)
}