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