[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
This commit is contained in:
Samuel Newman
2024-11-08 19:04:18 +00:00
committed by GitHub
parent e9d7c444ce
commit 7b5e8ae5ce
4 changed files with 273 additions and 104 deletions
@@ -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>

After

Width:  |  Height:  |  Size: 569 B

+76
View File
@@ -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>
)
}
+4
View File
@@ -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',
})
+192 -104
View File
@@ -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>
)
}