Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[Settings] Improved account switcher (#6131)

* move out avatarstack to own file

* improved settings switch

* prefix with @

* fix types

* up chevron

* respect reduced motion setting

* respect reduced motion in other place

authored by samuel.fm and committed by

GitHub 7b5e8ae5 e9d7c444

+273 -104
+1
assets/icons/personPlus_stroke2_corner2_rounded.svg
··· 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>
+76
src/components/AvatarStack.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {moderateProfile} from '@atproto/api' 4 + 5 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 6 + import {useProfilesQuery} from '#/state/queries/profile' 7 + import {UserAvatar} from '#/view/com/util/UserAvatar' 8 + import {atoms as a, useTheme} from '#/alf' 9 + 10 + export function AvatarStack({ 11 + profiles, 12 + size = 26, 13 + }: { 14 + profiles: string[] 15 + size?: number 16 + }) { 17 + const halfSize = size / 2 18 + const {data, error} = useProfilesQuery({handles: profiles}) 19 + const t = useTheme() 20 + const moderationOpts = useModerationOpts() 21 + 22 + if (error) { 23 + console.error(error) 24 + return null 25 + } 26 + 27 + const isPending = !data || !moderationOpts 28 + 29 + const items = isPending 30 + ? Array.from({length: profiles.length}).map((_, i) => ({ 31 + key: i, 32 + profile: null, 33 + moderation: null, 34 + })) 35 + : data.profiles.map(item => ({ 36 + key: item.did, 37 + profile: item, 38 + moderation: moderateProfile(item, moderationOpts), 39 + })) 40 + 41 + return ( 42 + <View 43 + style={[ 44 + a.flex_row, 45 + a.align_center, 46 + a.relative, 47 + {width: size + (items.length - 1) * halfSize}, 48 + ]}> 49 + {items.map((item, i) => ( 50 + <View 51 + key={item.key} 52 + style={[ 53 + t.atoms.bg_contrast_25, 54 + a.relative, 55 + { 56 + width: size, 57 + height: size, 58 + left: i * -halfSize, 59 + borderWidth: 1, 60 + borderColor: t.atoms.bg.backgroundColor, 61 + borderRadius: 999, 62 + zIndex: 3 - i, 63 + }, 64 + ]}> 65 + {item.profile && ( 66 + <UserAvatar 67 + size={size - 2} 68 + avatar={item.profile.avatar} 69 + moderation={item.moderation.ui('avatar')} 70 + /> 71 + )} 72 + </View> 73 + ))} 74 + </View> 75 + ) 76 + }
+4
src/components/icons/Person.tsx
··· 24 24 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', 25 25 }) 26 26 27 + export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({ 28 + 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', 29 + }) 30 + 27 31 export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({ 28 32 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', 29 33 })
+192 -104
src/screens/Settings/Settings.tsx
··· 1 1 import React, {useState} from 'react' 2 - import {LayoutAnimation, View} from 'react-native' 2 + import {LayoutAnimation, Pressable, View} from 'react-native' 3 3 import {Linking} from 'react-native' 4 + import {useReducedMotion} from 'react-native-reanimated' 4 5 import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' ··· 9 10 10 11 import {IS_INTERNAL} from '#/lib/app-info' 11 12 import {HELP_DESK_URL} from '#/lib/constants' 13 + import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 12 14 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 15 + import {sanitizeHandle} from '#/lib/strings/handles' 13 16 import {useProfileShadow} from '#/state/cache/profile-shadow' 14 17 import {clearStorage} from '#/state/persisted' 15 18 import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 19 import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' 17 20 import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' 18 - import {useSession, useSessionApi} from '#/state/session' 21 + import {SessionAccount, useSession, useSessionApi} from '#/state/session' 19 22 import {useOnboardingDispatch} from '#/state/shell' 20 23 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 21 24 import {useCloseAllActiveElements} from '#/state/util' ··· 24 27 import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName' 25 28 import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' 26 29 import * as SettingsList from '#/screens/Settings/components/SettingsList' 27 - import {atoms as a, useTheme} from '#/alf' 30 + import {atoms as a, tokens, useTheme} from '#/alf' 31 + import {AvatarStack} from '#/components/AvatarStack' 28 32 import {useDialogControl} from '#/components/Dialog' 29 33 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 30 34 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' 31 35 import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' 36 + import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' 32 37 import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' 33 38 import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 39 + import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 34 40 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 35 41 import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' 36 42 import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' 37 43 import { 38 44 Person_Stroke2_Corner2_Rounded as PersonIcon, 39 45 PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon, 46 + PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon, 47 + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, 40 48 } from '#/components/icons/Person' 41 49 import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' 42 50 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 43 51 import * as Layout from '#/components/Layout' 52 + import {Loader} from '#/components/Loader' 53 + import * as Menu from '#/components/Menu' 44 54 import * as Prompt from '#/components/Prompt' 45 55 46 56 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 47 57 export function SettingsScreen({}: Props) { 48 58 const {_} = useLingui() 59 + const reducedMotion = useReducedMotion() 49 60 const {logoutEveryAccount} = useSessionApi() 50 61 const {accounts, currentAccount} = useSession() 51 62 const switchAccountControl = useDialogControl() 52 63 const signOutPromptControl = Prompt.usePromptControl() 53 64 const {data: profile} = useProfileQuery({did: currentAccount?.did}) 54 - const {setShowLoggedOut} = useLoggedOutViewControls() 55 - const closeEverything = useCloseAllActiveElements() 65 + const {data: otherProfiles} = useProfilesQuery({ 66 + handles: accounts 67 + .filter(acc => acc.did !== currentAccount?.did) 68 + .map(acc => acc.handle), 69 + }) 70 + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() 71 + const [showAccounts, setShowAccounts] = useState(false) 56 72 const [showDevOptions, setShowDevOptions] = useState(false) 57 - 58 - const onAddAnotherAccount = () => { 59 - setShowLoggedOut(true) 60 - closeEverything() 61 - } 62 73 63 74 return ( 64 75 <Layout.Screen> ··· 77 88 ]}> 78 89 {profile && <ProfilePreview profile={profile} />} 79 90 </View> 80 - <SettingsList.PressableItem 81 - label={ 82 - accounts.length > 1 83 - ? _(msg`Switch account`) 84 - : _(msg`Add another account`) 85 - } 86 - onPress={() => 87 - accounts.length > 1 88 - ? switchAccountControl.open() 89 - : onAddAnotherAccount() 90 - }> 91 - <SettingsList.ItemIcon icon={PersonGroupIcon} /> 92 - <SettingsList.ItemText> 93 - {accounts.length > 1 ? ( 94 - <Trans>Switch account</Trans> 95 - ) : ( 96 - <Trans>Add another account</Trans> 91 + {accounts.length > 1 ? ( 92 + <> 93 + <SettingsList.PressableItem 94 + label={_(msg`Switch account`)} 95 + accessibilityHint={_( 96 + msg`Show other accounts you can switch to`, 97 + )} 98 + onPress={() => { 99 + if (!reducedMotion) { 100 + LayoutAnimation.configureNext( 101 + LayoutAnimation.Presets.easeInEaseOut, 102 + ) 103 + } 104 + setShowAccounts(s => !s) 105 + }}> 106 + <SettingsList.ItemIcon icon={PersonGroupIcon} /> 107 + <SettingsList.ItemText> 108 + <Trans>Switch account</Trans> 109 + </SettingsList.ItemText> 110 + {showAccounts ? ( 111 + <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> 112 + ) : ( 113 + <AvatarStack 114 + profiles={accounts 115 + .map(acc => acc.did) 116 + .filter(did => did !== currentAccount?.did) 117 + .slice(0, 5)} 118 + /> 119 + )} 120 + </SettingsList.PressableItem> 121 + {showAccounts && ( 122 + <> 123 + <SettingsList.Divider /> 124 + {accounts 125 + .filter(acc => acc.did !== currentAccount?.did) 126 + .map(account => ( 127 + <AccountRow 128 + key={account.did} 129 + account={account} 130 + profile={otherProfiles?.profiles?.find( 131 + p => p.did === account.did, 132 + )} 133 + pendingDid={pendingDid} 134 + onPressSwitchAccount={onPressSwitchAccount} 135 + /> 136 + ))} 137 + <AddAccountRow /> 138 + </> 97 139 )} 98 - </SettingsList.ItemText> 99 - {accounts.length > 1 && ( 100 - <AvatarStack 101 - profiles={accounts 102 - .map(acc => acc.did) 103 - .filter(did => did !== currentAccount?.did) 104 - .slice(0, 5)} 105 - /> 106 - )} 107 - </SettingsList.PressableItem> 140 + </> 141 + ) : ( 142 + <AddAccountRow /> 143 + )} 108 144 <SettingsList.Divider /> 109 145 <SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}> 110 146 <SettingsList.ItemIcon icon={PersonIcon} /> ··· 188 224 <SettingsList.Divider /> 189 225 <SettingsList.PressableItem 190 226 onPress={() => { 191 - LayoutAnimation.configureNext( 192 - LayoutAnimation.Presets.easeInEaseOut, 193 - ) 227 + if (!reducedMotion) { 228 + LayoutAnimation.configureNext( 229 + LayoutAnimation.Presets.easeInEaseOut, 230 + ) 231 + } 194 232 setShowDevOptions(d => !d) 195 233 }} 196 234 label={_(msg`Developer options`)}> ··· 245 283 ) 246 284 } 247 285 248 - const AVI_SIZE = 26 249 - const HALF_AVI_SIZE = AVI_SIZE / 2 250 - 251 - function AvatarStack({profiles}: {profiles: string[]}) { 252 - const {data, error} = useProfilesQuery({handles: profiles}) 253 - const t = useTheme() 254 - const moderationOpts = useModerationOpts() 255 - 256 - if (error) { 257 - console.error(error) 258 - return null 259 - } 260 - 261 - const isPending = !data || !moderationOpts 262 - 263 - const items = isPending 264 - ? Array.from({length: profiles.length}).map((_, i) => ({ 265 - key: i, 266 - profile: null, 267 - moderation: null, 268 - })) 269 - : data.profiles.map(item => ({ 270 - key: item.did, 271 - profile: item, 272 - moderation: moderateProfile(item, moderationOpts), 273 - })) 274 - 275 - return ( 276 - <View 277 - style={[ 278 - a.flex_row, 279 - a.align_center, 280 - a.relative, 281 - {width: AVI_SIZE + (items.length - 1) * HALF_AVI_SIZE}, 282 - ]}> 283 - {items.map((item, i) => ( 284 - <View 285 - key={item.key} 286 - style={[ 287 - t.atoms.bg_contrast_25, 288 - a.relative, 289 - { 290 - width: AVI_SIZE, 291 - height: AVI_SIZE, 292 - left: i * -HALF_AVI_SIZE, 293 - borderWidth: 1, 294 - borderColor: t.atoms.bg.backgroundColor, 295 - borderRadius: 999, 296 - zIndex: 3 - i, 297 - }, 298 - ]}> 299 - {item.profile && ( 300 - <UserAvatar 301 - size={AVI_SIZE - 2} 302 - avatar={item.profile.avatar} 303 - moderation={item.moderation.ui('avatar')} 304 - /> 305 - )} 306 - </View> 307 - ))} 308 - </View> 309 - ) 310 - } 311 - 312 286 function DevOptions() { 313 287 const {_} = useLingui() 314 288 const onboardingDispatch = useOnboardingDispatch() ··· 373 347 </> 374 348 ) 375 349 } 350 + 351 + function AddAccountRow() { 352 + const {_} = useLingui() 353 + const {setShowLoggedOut} = useLoggedOutViewControls() 354 + const closeEverything = useCloseAllActiveElements() 355 + 356 + const onAddAnotherAccount = () => { 357 + setShowLoggedOut(true) 358 + closeEverything() 359 + } 360 + 361 + return ( 362 + <SettingsList.PressableItem 363 + onPress={onAddAnotherAccount} 364 + label={_(msg`Add another account`)}> 365 + <SettingsList.ItemIcon icon={PersonPlusIcon} /> 366 + <SettingsList.ItemText> 367 + <Trans>Add another account</Trans> 368 + </SettingsList.ItemText> 369 + </SettingsList.PressableItem> 370 + ) 371 + } 372 + 373 + function AccountRow({ 374 + profile, 375 + account, 376 + pendingDid, 377 + onPressSwitchAccount, 378 + }: { 379 + profile?: AppBskyActorDefs.ProfileViewDetailed 380 + account: SessionAccount 381 + pendingDid: string | null 382 + onPressSwitchAccount: ( 383 + account: SessionAccount, 384 + logContext: 'Settings', 385 + ) => void 386 + }) { 387 + const {_} = useLingui() 388 + const t = useTheme() 389 + 390 + const moderationOpts = useModerationOpts() 391 + const removePromptControl = Prompt.usePromptControl() 392 + const {removeAccount} = useSessionApi() 393 + 394 + const onSwitchAccount = () => { 395 + if (pendingDid) return 396 + onPressSwitchAccount(account, 'Settings') 397 + } 398 + 399 + return ( 400 + <View style={[a.relative]}> 401 + <SettingsList.PressableItem 402 + onPress={onSwitchAccount} 403 + label={_(msg`Switch account`)}> 404 + {moderationOpts && profile ? ( 405 + <UserAvatar 406 + size={28} 407 + avatar={profile.avatar} 408 + moderation={moderateProfile(profile, moderationOpts).ui('avatar')} 409 + /> 410 + ) : ( 411 + <View style={[{width: 28}]} /> 412 + )} 413 + <SettingsList.ItemText> 414 + <Trans>{sanitizeHandle(account.handle, '@')}</Trans> 415 + </SettingsList.ItemText> 416 + {pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />} 417 + </SettingsList.PressableItem> 418 + {!pendingDid && ( 419 + <Menu.Root> 420 + <Menu.Trigger label={_(msg`Account options`)}> 421 + {({props, state}) => ( 422 + <Pressable 423 + {...props} 424 + style={[ 425 + a.absolute, 426 + {top: 10, right: tokens.space.lg}, 427 + a.p_xs, 428 + a.rounded_full, 429 + (state.hovered || state.pressed) && t.atoms.bg_contrast_25, 430 + ]}> 431 + <DotsHorizontal size="md" style={t.atoms.text} /> 432 + </Pressable> 433 + )} 434 + </Menu.Trigger> 435 + <Menu.Outer showCancel> 436 + <Menu.Item 437 + label={_(msg`Remove account`)} 438 + onPress={() => removePromptControl.open()}> 439 + <Menu.ItemText> 440 + <Trans>Remove account</Trans> 441 + </Menu.ItemText> 442 + <Menu.ItemIcon icon={PersonXIcon} /> 443 + </Menu.Item> 444 + </Menu.Outer> 445 + </Menu.Root> 446 + )} 447 + 448 + <Prompt.Basic 449 + control={removePromptControl} 450 + title={_(msg`Remove from quick access?`)} 451 + description={_( 452 + msg`This will remove @${account.handle} from the quick access list.`, 453 + )} 454 + onConfirm={() => { 455 + removeAccount(account) 456 + Toast.show(_(msg`Account removed from quick access`)) 457 + }} 458 + confirmButtonCta={_(msg`Remove`)} 459 + confirmButtonColor="negative" 460 + /> 461 + </View> 462 + ) 463 + }