Settings revamp (#5745)

* start building storybook

* add title

* add some styles

* try out new icons

* more settings list component parts

* make text do the spacing

* clean up storybook

* gated new settings screen

* switch account

* add current profile

* use Layout.Screen

* Layout.Header and Layout.Content

* translate helpdesk text

thanks @surfdude29!

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

* add account settings

* undo changes to export car dialog

* privacy and security screen

* Translate protect account stuff

Thanks @surfdude29!

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

* content and media settings

* about settings

* 2fa copy

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

* a11y and appearance

* use new components for appearance settings

* redesign accessibility settings

* Update ContentAndMediaSettings.tsx

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

* add divider

* remove a11y and appearance middleman screen

* fix web settingslist styles

* new SettingsList.Group component

* explain how portal magic works

* hide pwioptout in old location

* Update Settings.tsx

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

* replace gate with `IS_INTERNAL`

* add IS_INTERNAL to app-info.web

* fix profile area growing

* add close button to switch account

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Samuel Newman
2024-10-29 21:14:54 +00:00
committed by GitHub
parent ab492cd77a
commit c8f264b78b
59 changed files with 1811 additions and 133 deletions
@@ -0,0 +1,66 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useAgent, useSession} from '#/state/session'
import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import * as SettingsList from './SettingsList'
export function Email2FAToggle() {
const {_} = useLingui()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const disableDialogControl = useDialogControl()
const enableDialogControl = useDialogControl()
const agent = useAgent()
const enableEmailAuthFactor = React.useCallback(async () => {
if (currentAccount?.email) {
await agent.com.atproto.server.updateEmail({
email: currentAccount.email,
emailAuthFactor: true,
})
await agent.resumeSession(agent.session!)
}
}, [currentAccount, agent])
const onToggle = React.useCallback(() => {
if (!currentAccount) {
return
}
if (currentAccount.emailAuthFactor) {
disableDialogControl.open()
} else {
if (!currentAccount.emailConfirmed) {
openModal({
name: 'verify-email',
onSuccess: enableDialogControl.open,
})
return
}
enableDialogControl.open()
}
}, [currentAccount, enableDialogControl, openModal, disableDialogControl])
return (
<>
<DisableEmail2FADialog control={disableDialogControl} />
<Prompt.Basic
control={enableDialogControl}
title={_(msg`Enable Email 2FA`)}
description={_(msg`Require an email code to log in to your account.`)}
onConfirm={enableEmailAuthFactor}
confirmButtonCta={_(msg`Enable`)}
/>
<SettingsList.BadgeButton
label={
currentAccount?.emailAuthFactor ? _(msg`Disable`) : _(msg`Enable`)
}
onPress={onToggle}
/>
</>
)
}
@@ -0,0 +1,100 @@
import React from 'react'
import {View} from 'react-native'
import {ComAtprotoLabelDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
useProfileQuery,
useProfileUpdateMutation,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Text} from '#/components/Typography'
export function PwiOptOut() {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const updateProfile = useProfileUpdateMutation()
const isOptedOut =
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
const canToggle = profile && !updateProfile.isPending
const onToggleOptOut = React.useCallback(() => {
if (!profile) {
return
}
let wasAdded = false
updateProfile.mutate({
profile,
updates: existing => {
// create labels attr if needed
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
? existing.labels
: {
$type: 'com.atproto.label.defs#selfLabels',
values: [],
}
// toggle the label
const hasLabel = existing.labels.values.some(
l => l.val === '!no-unauthenticated',
)
if (hasLabel) {
wasAdded = false
existing.labels.values = existing.labels.values.filter(
l => l.val !== '!no-unauthenticated',
)
} else {
wasAdded = true
existing.labels.values.push({val: '!no-unauthenticated'})
}
// delete if no longer needed
if (existing.labels.values.length === 0) {
delete existing.labels
}
return existing
},
checkCommitted: res => {
const exists = !!res.data.labels?.some(
l => l.val === '!no-unauthenticated',
)
return exists === wasAdded
},
})
}, [updateProfile, profile])
return (
<View style={[a.flex_1, a.gap_sm]}>
<Toggle.Item
name="logged_out_visibility"
disabled={!canToggle || updateProfile.isPending}
value={isOptedOut}
onChange={onToggleOptOut}
label={_(
msg`Discourage apps from showing my account to logged-out users`,
)}
style={[a.w_full]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Discourage apps from showing my account to logged-out users
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
Bluesky will not show your profile and posts to logged-out users.
Other apps may not honor this request. This does not make your account
private.
</Trans>
</Text>
</View>
)
}
@@ -0,0 +1,300 @@
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 * as Button from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Link, LinkProps} from '#/components/Link'
import {createPortalGroup} from '#/components/Portal'
import {Text} from '#/components/Typography'
const ItemContext = React.createContext({
destructive: false,
withinGroup: false,
})
const Portal = createPortalGroup()
export function Container({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.py_lg]}>{children}</View>
}
/**
* This uses `Portal` magic ✨ to render the icons and title correctly. ItemIcon and ItemText components
* get teleported to the top row, leaving the rest of the children in the bottom row.
*/
export function Group({
children,
destructive = false,
iconInset = true,
style,
contentContainerStyle,
}: {
children: React.ReactNode
destructive?: boolean
iconInset?: boolean
style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle>
}) {
const context = useMemo(
() => ({destructive, withinGroup: true}),
[destructive],
)
return (
<View style={[a.w_full, style]}>
<Portal.Provider>
<ItemContext.Provider value={context}>
<Item style={[a.pb_2xs, {minHeight: 42}]}>
<Portal.Outlet />
</Item>
<Item
style={[
a.flex_col,
a.pt_2xs,
a.align_start,
a.gap_0,
contentContainerStyle,
]}
iconInset={iconInset}>
{children}
</Item>
</ItemContext.Provider>
</Portal.Provider>
</View>
)
}
export function Item({
children,
destructive,
iconInset = false,
style,
}: {
children?: React.ReactNode
destructive?: boolean
/**
* Adds left padding so that the content will be aligned with other Items that contain icons
* @default false
*/
iconInset?: boolean
style?: StyleProp<ViewStyle>
}) {
const context = useContext(ItemContext)
const childContext = useMemo(() => {
if (typeof destructive !== 'boolean') return context
return {...context, destructive}
}, [context, destructive])
return (
<View
style={[
a.px_xl,
a.py_sm,
a.align_center,
a.gap_md,
a.w_full,
a.flex_row,
{minHeight: 48},
iconInset && {
paddingLeft:
// existing padding
a.pl_xl.paddingLeft +
// icon
28 +
// gap
a.gap_md.gap,
},
style,
]}>
<ItemContext.Provider value={childContext}>
{children}
</ItemContext.Provider>
</View>
)
}
export function LinkItem({
children,
destructive = false,
contentContainerStyle,
chevronColor,
...props
}: LinkProps & {
contentContainerStyle?: StyleProp<ViewStyle>
destructive?: boolean
chevronColor?: string
}) {
const t = useTheme()
return (
<Link color="secondary" {...props}>
{args => (
<Item
destructive={destructive}
style={[
(args.hovered || args.pressed) && [t.atoms.bg_contrast_25],
contentContainerStyle,
]}>
{typeof children === 'function' ? children(args) : children}
<Chevron color={chevronColor} />
</Item>
)}
</Link>
)
}
export function PressableItem({
children,
destructive = false,
contentContainerStyle,
hoverStyle,
...props
}: Button.ButtonProps & {
contentContainerStyle?: StyleProp<ViewStyle>
destructive?: boolean
}) {
const t = useTheme()
return (
<Button.Button {...props}>
{args => (
<Item
destructive={destructive}
style={[
(args.hovered || args.pressed) && [
t.atoms.bg_contrast_25,
hoverStyle,
],
contentContainerStyle,
]}>
{typeof children === 'function' ? children(args) : children}
</Item>
)}
</Button.Button>
)
}
export function ItemIcon({
icon: Comp,
size = 'xl',
color: colorProp,
}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
color?: string
}) {
const t = useTheme()
const {destructive, withinGroup} = useContext(ItemContext)
/*
* Copied here from icons/common.tsx so we can tweak if we need to, but
* also so that we can calculate transforms.
*/
const iconSize = {
xs: 12,
sm: 16,
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
}[size]
const color =
colorProp ?? (destructive ? t.palette.negative_500 : t.atoms.text.color)
const content = (
<View style={[a.z_20, {width: iconSize, height: iconSize}]}>
<Comp width={iconSize} style={[{color}]} />
</View>
)
if (withinGroup) {
return <Portal.Portal>{content}</Portal.Portal>
} else {
return content
}
}
export function ItemText({
// eslint-disable-next-line react/prop-types
style,
...props
}: React.ComponentProps<typeof Button.ButtonText>) {
const t = useTheme()
const {destructive, withinGroup} = useContext(ItemContext)
const content = (
<Button.ButtonText
style={[
a.text_md,
a.font_normal,
a.text_left,
a.flex_1,
destructive ? {color: t.palette.negative_500} : t.atoms.text,
style,
]}
{...props}
/>
)
if (withinGroup) {
return <Portal.Portal>{content}</Portal.Portal>
} else {
return content
}
}
export function Divider() {
const t = useTheme()
return (
<View
style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]}
/>
)
}
export function Chevron({color: colorProp}: {color?: string}) {
const {destructive} = useContext(ItemContext)
const t = useTheme()
const color =
colorProp ?? (destructive ? t.palette.negative_500 : t.palette.contrast_500)
return <ItemIcon icon={ChevronRightIcon} size="md" color={color} />
}
export function BadgeText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
style={[
t.atoms.text_contrast_low,
a.text_md,
a.text_right,
a.leading_snug,
]}
numberOfLines={1}>
{children}
</Text>
)
}
export function BadgeButton({
label,
onPress,
}: {
label: string
onPress: (evt: GestureResponderEvent) => void
}) {
const t = useTheme()
return (
<Button.Button label={label} onPress={onPress} hitSlop={HITSLOP_10}>
{({pressed}) => (
<Button.ButtonText
style={[
a.text_md,
a.font_normal,
a.text_right,
{color: pressed ? t.palette.contrast_300 : t.palette.primary_500},
]}>
{label}
</Button.ButtonText>
)}
</Button.Button>
)
}