diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-11-08 19:04:18 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-08 19:04:18 +0000 |
commit | 7b5e8ae5ce674c8946b70ee7c8f01f4b8ab96598 (patch) | |
tree | 268dc830771156a73b4ce27231f55fe742b451ce /src | |
parent | e9d7c444cea0d60b4d2fe1b3c7195edd3db98e5b (diff) | |
download | voidsky-7b5e8ae5ce674c8946b70ee7c8f01f4b8ab96598.tar.zst |
[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
Diffstat (limited to 'src')
-rw-r--r-- | src/components/AvatarStack.tsx | 76 | ||||
-rw-r--r-- | src/components/icons/Person.tsx | 4 | ||||
-rw-r--r-- | src/screens/Settings/Settings.tsx | 296 |
3 files changed, 272 insertions, 104 deletions
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> + ) +} |