about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/personPlus_stroke2_corner2_rounded.svg1
-rw-r--r--src/components/AvatarStack.tsx76
-rw-r--r--src/components/icons/Person.tsx4
-rw-r--r--src/screens/Settings/Settings.tsx296
4 files changed, 273 insertions, 104 deletions
diff --git a/assets/icons/personPlus_stroke2_corner2_rounded.svg b/assets/icons/personPlus_stroke2_corner2_rounded.svg
new file mode 100644
index 000000000..148c29e0f
--- /dev/null
+++ b/assets/icons/personPlus_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx
new file mode 100644
index 000000000..5f790fb67
--- /dev/null
+++ b/src/components/AvatarStack.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import {View} from 'react-native'
+import {moderateProfile} from '@atproto/api'
+
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useProfilesQuery} from '#/state/queries/profile'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+
+export function AvatarStack({
+  profiles,
+  size = 26,
+}: {
+  profiles: string[]
+  size?: number
+}) {
+  const halfSize = size / 2
+  const {data, error} = useProfilesQuery({handles: profiles})
+  const t = useTheme()
+  const moderationOpts = useModerationOpts()
+
+  if (error) {
+    console.error(error)
+    return null
+  }
+
+  const isPending = !data || !moderationOpts
+
+  const items = isPending
+    ? Array.from({length: profiles.length}).map((_, i) => ({
+        key: i,
+        profile: null,
+        moderation: null,
+      }))
+    : data.profiles.map(item => ({
+        key: item.did,
+        profile: item,
+        moderation: moderateProfile(item, moderationOpts),
+      }))
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.relative,
+        {width: size + (items.length - 1) * halfSize},
+      ]}>
+      {items.map((item, i) => (
+        <View
+          key={item.key}
+          style={[
+            t.atoms.bg_contrast_25,
+            a.relative,
+            {
+              width: size,
+              height: size,
+              left: i * -halfSize,
+              borderWidth: 1,
+              borderColor: t.atoms.bg.backgroundColor,
+              borderRadius: 999,
+              zIndex: 3 - i,
+            },
+          ]}>
+          {item.profile && (
+            <UserAvatar
+              size={size - 2}
+              avatar={item.profile.avatar}
+              moderation={item.moderation.ui('avatar')}
+            />
+          )}
+        </View>
+      ))}
+    </View>
+  )
+}
diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx
index 31d7078d9..8428fcce1 100644
--- a/src/components/icons/Person.tsx
+++ b/src/components/icons/Person.tsx
@@ -24,6 +24,10 @@ export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
 })
 
+export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
+})
+
 export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({
   path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z',
 })
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index 2062fe918..adea42e5e 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -1,6 +1,7 @@
 import React, {useState} from 'react'
-import {LayoutAnimation, View} from 'react-native'
+import {LayoutAnimation, Pressable, View} from 'react-native'
 import {Linking} from 'react-native'
+import {useReducedMotion} from 'react-native-reanimated'
 import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -9,13 +10,15 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {IS_INTERNAL} from '#/lib/app-info'
 import {HELP_DESK_URL} from '#/lib/constants'
+import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {sanitizeHandle} from '#/lib/strings/handles'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {clearStorage} from '#/state/persisted'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
 import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
-import {useSession, useSessionApi} from '#/state/session'
+import {SessionAccount, useSession, useSessionApi} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -24,42 +27,50 @@ import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
 import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {AvatarStack} from '#/components/AvatarStack'
 import {useDialogControl} from '#/components/Dialog'
 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
 import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
+import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
 import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
 import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
 import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
 import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
 import {
   Person_Stroke2_Corner2_Rounded as PersonIcon,
   PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon,
+  PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon,
+  PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
 } from '#/components/icons/Person'
 import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
 import * as Layout from '#/components/Layout'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
 export function SettingsScreen({}: Props) {
   const {_} = useLingui()
+  const reducedMotion = useReducedMotion()
   const {logoutEveryAccount} = useSessionApi()
   const {accounts, currentAccount} = useSession()
   const switchAccountControl = useDialogControl()
   const signOutPromptControl = Prompt.usePromptControl()
   const {data: profile} = useProfileQuery({did: currentAccount?.did})
-  const {setShowLoggedOut} = useLoggedOutViewControls()
-  const closeEverything = useCloseAllActiveElements()
+  const {data: otherProfiles} = useProfilesQuery({
+    handles: accounts
+      .filter(acc => acc.did !== currentAccount?.did)
+      .map(acc => acc.handle),
+  })
+  const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
+  const [showAccounts, setShowAccounts] = useState(false)
   const [showDevOptions, setShowDevOptions] = useState(false)
 
-  const onAddAnotherAccount = () => {
-    setShowLoggedOut(true)
-    closeEverything()
-  }
-
   return (
     <Layout.Screen>
       <Layout.Header title={_(msg`Settings`)} />
@@ -77,34 +88,59 @@ export function SettingsScreen({}: Props) {
             ]}>
             {profile && <ProfilePreview profile={profile} />}
           </View>
-          <SettingsList.PressableItem
-            label={
-              accounts.length > 1
-                ? _(msg`Switch account`)
-                : _(msg`Add another account`)
-            }
-            onPress={() =>
-              accounts.length > 1
-                ? switchAccountControl.open()
-                : onAddAnotherAccount()
-            }>
-            <SettingsList.ItemIcon icon={PersonGroupIcon} />
-            <SettingsList.ItemText>
-              {accounts.length > 1 ? (
-                <Trans>Switch account</Trans>
-              ) : (
-                <Trans>Add another account</Trans>
+          {accounts.length > 1 ? (
+            <>
+              <SettingsList.PressableItem
+                label={_(msg`Switch account`)}
+                accessibilityHint={_(
+                  msg`Show other accounts you can switch to`,
+                )}
+                onPress={() => {
+                  if (!reducedMotion) {
+                    LayoutAnimation.configureNext(
+                      LayoutAnimation.Presets.easeInEaseOut,
+                    )
+                  }
+                  setShowAccounts(s => !s)
+                }}>
+                <SettingsList.ItemIcon icon={PersonGroupIcon} />
+                <SettingsList.ItemText>
+                  <Trans>Switch account</Trans>
+                </SettingsList.ItemText>
+                {showAccounts ? (
+                  <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
+                ) : (
+                  <AvatarStack
+                    profiles={accounts
+                      .map(acc => acc.did)
+                      .filter(did => did !== currentAccount?.did)
+                      .slice(0, 5)}
+                  />
+                )}
+              </SettingsList.PressableItem>
+              {showAccounts && (
+                <>
+                  <SettingsList.Divider />
+                  {accounts
+                    .filter(acc => acc.did !== currentAccount?.did)
+                    .map(account => (
+                      <AccountRow
+                        key={account.did}
+                        account={account}
+                        profile={otherProfiles?.profiles?.find(
+                          p => p.did === account.did,
+                        )}
+                        pendingDid={pendingDid}
+                        onPressSwitchAccount={onPressSwitchAccount}
+                      />
+                    ))}
+                  <AddAccountRow />
+                </>
               )}
-            </SettingsList.ItemText>
-            {accounts.length > 1 && (
-              <AvatarStack
-                profiles={accounts
-                  .map(acc => acc.did)
-                  .filter(did => did !== currentAccount?.did)
-                  .slice(0, 5)}
-              />
-            )}
-          </SettingsList.PressableItem>
+            </>
+          ) : (
+            <AddAccountRow />
+          )}
           <SettingsList.Divider />
           <SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}>
             <SettingsList.ItemIcon icon={PersonIcon} />
@@ -188,9 +224,11 @@ export function SettingsScreen({}: Props) {
               <SettingsList.Divider />
               <SettingsList.PressableItem
                 onPress={() => {
-                  LayoutAnimation.configureNext(
-                    LayoutAnimation.Presets.easeInEaseOut,
-                  )
+                  if (!reducedMotion) {
+                    LayoutAnimation.configureNext(
+                      LayoutAnimation.Presets.easeInEaseOut,
+                    )
+                  }
                   setShowDevOptions(d => !d)
                 }}
                 label={_(msg`Developer options`)}>
@@ -245,70 +283,6 @@ function ProfilePreview({
   )
 }
 
-const AVI_SIZE = 26
-const HALF_AVI_SIZE = AVI_SIZE / 2
-
-function AvatarStack({profiles}: {profiles: string[]}) {
-  const {data, error} = useProfilesQuery({handles: profiles})
-  const t = useTheme()
-  const moderationOpts = useModerationOpts()
-
-  if (error) {
-    console.error(error)
-    return null
-  }
-
-  const isPending = !data || !moderationOpts
-
-  const items = isPending
-    ? Array.from({length: profiles.length}).map((_, i) => ({
-        key: i,
-        profile: null,
-        moderation: null,
-      }))
-    : data.profiles.map(item => ({
-        key: item.did,
-        profile: item,
-        moderation: moderateProfile(item, moderationOpts),
-      }))
-
-  return (
-    <View
-      style={[
-        a.flex_row,
-        a.align_center,
-        a.relative,
-        {width: AVI_SIZE + (items.length - 1) * HALF_AVI_SIZE},
-      ]}>
-      {items.map((item, i) => (
-        <View
-          key={item.key}
-          style={[
-            t.atoms.bg_contrast_25,
-            a.relative,
-            {
-              width: AVI_SIZE,
-              height: AVI_SIZE,
-              left: i * -HALF_AVI_SIZE,
-              borderWidth: 1,
-              borderColor: t.atoms.bg.backgroundColor,
-              borderRadius: 999,
-              zIndex: 3 - i,
-            },
-          ]}>
-          {item.profile && (
-            <UserAvatar
-              size={AVI_SIZE - 2}
-              avatar={item.profile.avatar}
-              moderation={item.moderation.ui('avatar')}
-            />
-          )}
-        </View>
-      ))}
-    </View>
-  )
-}
-
 function DevOptions() {
   const {_} = useLingui()
   const onboardingDispatch = useOnboardingDispatch()
@@ -373,3 +347,117 @@ function DevOptions() {
     </>
   )
 }
+
+function AddAccountRow() {
+  const {_} = useLingui()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+  const closeEverything = useCloseAllActiveElements()
+
+  const onAddAnotherAccount = () => {
+    setShowLoggedOut(true)
+    closeEverything()
+  }
+
+  return (
+    <SettingsList.PressableItem
+      onPress={onAddAnotherAccount}
+      label={_(msg`Add another account`)}>
+      <SettingsList.ItemIcon icon={PersonPlusIcon} />
+      <SettingsList.ItemText>
+        <Trans>Add another account</Trans>
+      </SettingsList.ItemText>
+    </SettingsList.PressableItem>
+  )
+}
+
+function AccountRow({
+  profile,
+  account,
+  pendingDid,
+  onPressSwitchAccount,
+}: {
+  profile?: AppBskyActorDefs.ProfileViewDetailed
+  account: SessionAccount
+  pendingDid: string | null
+  onPressSwitchAccount: (
+    account: SessionAccount,
+    logContext: 'Settings',
+  ) => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const moderationOpts = useModerationOpts()
+  const removePromptControl = Prompt.usePromptControl()
+  const {removeAccount} = useSessionApi()
+
+  const onSwitchAccount = () => {
+    if (pendingDid) return
+    onPressSwitchAccount(account, 'Settings')
+  }
+
+  return (
+    <View style={[a.relative]}>
+      <SettingsList.PressableItem
+        onPress={onSwitchAccount}
+        label={_(msg`Switch account`)}>
+        {moderationOpts && profile ? (
+          <UserAvatar
+            size={28}
+            avatar={profile.avatar}
+            moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
+          />
+        ) : (
+          <View style={[{width: 28}]} />
+        )}
+        <SettingsList.ItemText>
+          <Trans>{sanitizeHandle(account.handle, '@')}</Trans>
+        </SettingsList.ItemText>
+        {pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />}
+      </SettingsList.PressableItem>
+      {!pendingDid && (
+        <Menu.Root>
+          <Menu.Trigger label={_(msg`Account options`)}>
+            {({props, state}) => (
+              <Pressable
+                {...props}
+                style={[
+                  a.absolute,
+                  {top: 10, right: tokens.space.lg},
+                  a.p_xs,
+                  a.rounded_full,
+                  (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <DotsHorizontal size="md" style={t.atoms.text} />
+              </Pressable>
+            )}
+          </Menu.Trigger>
+          <Menu.Outer showCancel>
+            <Menu.Item
+              label={_(msg`Remove account`)}
+              onPress={() => removePromptControl.open()}>
+              <Menu.ItemText>
+                <Trans>Remove account</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={PersonXIcon} />
+            </Menu.Item>
+          </Menu.Outer>
+        </Menu.Root>
+      )}
+
+      <Prompt.Basic
+        control={removePromptControl}
+        title={_(msg`Remove from quick access?`)}
+        description={_(
+          msg`This will remove @${account.handle} from the quick access list.`,
+        )}
+        onConfirm={() => {
+          removeAccount(account)
+          Toast.show(_(msg`Account removed from quick access`))
+        }}
+        confirmButtonCta={_(msg`Remove`)}
+        confirmButtonColor="negative"
+      />
+    </View>
+  )
+}