diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-10-29 21:14:54 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-10-29 21:14:54 +0000 |
commit | c8f264b78b1dfb95f68bfb820bd012828cd5fddc (patch) | |
tree | eca795959b8980d14a19169be8f0e71850bfc091 /src/screens/Settings/components | |
parent | ab492cd77a2588c58899793d5a51c7d4dd0a4968 (diff) | |
download | voidsky-c8f264b78b1dfb95f68bfb820bd012828cd5fddc.tar.zst |
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>
Diffstat (limited to 'src/screens/Settings/components')
-rw-r--r-- | src/screens/Settings/components/Email2FAToggle.tsx | 66 | ||||
-rw-r--r-- | src/screens/Settings/components/PwiOptOut.tsx | 100 | ||||
-rw-r--r-- | src/screens/Settings/components/SettingsList.tsx | 300 |
3 files changed, 466 insertions, 0 deletions
diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx new file mode 100644 index 000000000..d89e5f18e --- /dev/null +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -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} + /> + </> + ) +} diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx new file mode 100644 index 000000000..4339ade9b --- /dev/null +++ b/src/screens/Settings/components/PwiOptOut.tsx @@ -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> + ) +} diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx new file mode 100644 index 000000000..86f8040af --- /dev/null +++ b/src/screens/Settings/components/SettingsList.tsx @@ -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> + ) +} |