about summary refs log tree commit diff
path: root/src/screens/Settings/components
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-10-29 21:14:54 +0000
committerGitHub <noreply@github.com>2024-10-29 21:14:54 +0000
commitc8f264b78b1dfb95f68bfb820bd012828cd5fddc (patch)
treeeca795959b8980d14a19169be8f0e71850bfc091 /src/screens/Settings/components
parentab492cd77a2588c58899793d5a51c7d4dd0a4968 (diff)
downloadvoidsky-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.tsx66
-rw-r--r--src/screens/Settings/components/PwiOptOut.tsx100
-rw-r--r--src/screens/Settings/components/SettingsList.tsx300
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>
+  )
+}