Account quick switch modal (#1567)

* quick switch menu

* Some small tweaks and fixes to the account switch modal

* Factor out the account switcher logic to a hook

* Add haptic feedback on account switcher open

* Fix bad merge

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
zio/stable
Paul Frazee 2023-09-28 12:41:44 -07:00 committed by GitHub
parent 3e340b336e
commit 2e5f73ff61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 243 additions and 59 deletions

View File

@ -246,6 +246,7 @@ function TabsNavigator() {
), ),
[], [],
) )
return ( return (
<Tab.Navigator <Tab.Navigator
initialRouteName="HomeTab" initialRouteName="HomeTab"

View File

@ -0,0 +1,41 @@
import {useCallback, useState} from 'react'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
import {StackActions, useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {AccountData} from 'state/models/session'
import {reset as resetNavigation} from '../../Navigation'
import * as Toast from 'view/com/util/Toast'
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, setIsSwitching] = useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressSwitchAccount = useCallback(
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
},
[track, setIsSwitching, navigation, store],
)
return [isSwitching, setIsSwitching, onPressSwitchAccount]
}

View File

@ -150,6 +150,10 @@ export interface ChangeEmailModal {
name: 'change-email' name: 'change-email'
} }
export interface SwitchAccountModal {
name: 'switch-account'
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@ -160,6 +164,7 @@ export type Modal =
| BirthDateSettingsModal | BirthDateSettingsModal
| VerifyEmailModal | VerifyEmailModal
| ChangeEmailModal | ChangeEmailModal
| SwitchAccountModal
// Curation // Curation
| ContentFilteringSettingsModal | ContentFilteringSettingsModal

View File

@ -32,6 +32,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings' import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
@ -144,6 +145,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'change-email') { } else if (activeModal?.name === 'change-email') {
snapPoints = ChangeEmailModal.snapPoints snapPoints = ChangeEmailModal.snapPoints
element = <ChangeEmailModal.Component /> element = <ChangeEmailModal.Component />
} else if (activeModal?.name === 'switch-account') {
snapPoints = SwitchAccountModal.snapPoints
element = <SwitchAccountModal.Component />
} else { } else {
return null return null
} }

View File

@ -0,0 +1,136 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {Text} from '../util/text/Text'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {UserAvatar} from '../util/UserAvatar'
import {AccountDropdownBtn} from '../util/AccountDropdownBtn'
import {Link} from '../util/Link'
import {makeProfileLink} from 'lib/routes/links'
import {BottomSheetScrollView} from '@gorhom/bottom-sheet'
import {Haptics} from 'lib/haptics'
export const snapPoints = ['40%', '90%']
export function Component({}: {}) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher()
React.useEffect(() => {
Haptics.default()
})
const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked')
store.session.logout()
}, [track, store])
return (
<View style={[styles.container, pal.view]}>
<Text type="title-xl" style={[styles.title, pal.text]}>
Switch Account
</Text>
<BottomSheetScrollView
style={styles.container}
contentContainerStyle={[styles.innerContainer, pal.view]}>
{isSwitching ? (
<View style={[pal.view, styles.linkCard]}>
<ActivityIndicator />
</View>
) : (
<Link
href={makeProfileLink(store.me)}
title="Your profile"
noFeedback>
<View style={[pal.view, styles.linkCard]}>
<View style={styles.avi}>
<UserAvatar size={40} avatar={store.me.avatar} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text} numberOfLines={1}>
{store.me.displayName || store.me.handle}
</Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}>
{store.me.handle}
</Text>
</View>
<TouchableOpacity
testID="signOutBtn"
onPress={isSwitching ? undefined : onPressSignout}
accessibilityRole="button"
accessibilityLabel="Sign out"
accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}>
<Text type="lg" style={pal.link}>
Sign out
</Text>
</TouchableOpacity>
</View>
</Link>
)}
{store.session.switchableAccounts.map(account => (
<TouchableOpacity
testID={`switchToAccountBtn-${account.handle}`}
key={account.did}
style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]}
onPress={
isSwitching ? undefined : () => onPressSwitchAccount(account)
}
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.handle}`}
accessibilityHint="Switches the account you are logged in to">
<View style={styles.avi}>
<UserAvatar size={40} avatar={account.aviUrl} />
</View>
<View style={[s.flex1]}>
<Text type="md-bold" style={pal.text}>
{account.displayName || account.handle}
</Text>
<Text type="sm" style={pal.textLight}>
{account.handle}
</Text>
</View>
<AccountDropdownBtn handle={account.handle} />
</TouchableOpacity>
))}
</BottomSheetScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
innerContainer: {
paddingBottom: 20,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
linkCard: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
marginBottom: 1,
},
avi: {
marginRight: 12,
},
dimmed: {
opacity: 0.5,
},
})

View File

@ -0,0 +1,46 @@
import React from 'react'
import {Pressable} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import * as Toast from '../../com/util/Toast'
export function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores()
const pal = usePalette('default')
const items: DropdownItem[] = [
{
label: 'Remove account',
onPress: () => {
store.session.removeAccount(handle)
Toast.show('Account removed from quick access')
},
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: 'trash',
},
},
]
return (
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown
testID="accountSettingsDropdownBtn"
items={items}
accessibilityLabel="Account options"
accessibilityHint="">
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</NativeDropdown>
</Pressable>
)
}

View File

@ -71,6 +71,7 @@ export const NotificationsScreen = withAuthRequired(
} }
}, [store, screen, onPressLoadLatest]), }, [store, screen, onPressLoadLatest]),
) )
useTabFocusEffect( useTabFocusEffect(
'Notifications', 'Notifications',
React.useCallback( React.useCallback(

View File

@ -3,8 +3,8 @@ import {
ActivityIndicator, ActivityIndicator,
Linking, Linking,
Platform, Platform,
Pressable,
StyleSheet, StyleSheet,
Pressable,
TextStyle, TextStyle,
TouchableOpacity, TouchableOpacity,
View, View,
@ -36,22 +36,21 @@ import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette' import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {AccountData} from 'state/models/session' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {HandIcon, HashtagIcon} from 'lib/icons' import {HandIcon, HashtagIcon} from 'lib/icons'
import {formatCount} from 'view/com/util/numeric/format' import {formatCount} from 'view/com/util/numeric/format'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {reset as resetNavigation} from '../../Navigation'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
// TEMPORARY (APP-700) // TEMPORARY (APP-700)
// remove after backend testing finishes // remove after backend testing finishes
// -prf // -prf
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header' import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
import {STATUS_PAGE_URL} from 'lib/constants' import {STATUS_PAGE_URL} from 'lib/constants'
import {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired( export const SettingsScreen = withAuthRequired(
@ -61,7 +60,8 @@ export const SettingsScreen = withAuthRequired(
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const [isSwitching, setIsSwitching] = React.useState(false) const [isSwitching, setIsSwitching, onPressSwitchAccount] =
useAccountSwitcher()
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
store.agent, store.agent,
) )
@ -91,25 +91,6 @@ export const SettingsScreen = withAuthRequired(
}, [screen, store]), }, [screen, store]),
) )
const onPressSwitchAccount = React.useCallback(
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
setIsSwitching(true)
if (await store.session.resumeSession(acct)) {
setIsSwitching(false)
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
return
}
setIsSwitching(false)
Toast.show('Sorry! We need you to enter your password.')
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
},
[track, setIsSwitching, navigation, store],
)
const onPressAddAccount = React.useCallback(() => { const onPressAddAccount = React.useCallback(() => {
track('Settings:AddAccountButtonClicked') track('Settings:AddAccountButtonClicked')
navigation.navigate('HomeTab') navigation.navigate('HomeTab')
@ -646,41 +627,6 @@ export const SettingsScreen = withAuthRequired(
}), }),
) )
function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores()
const pal = usePalette('default')
const items: DropdownItem[] = [
{
label: 'Remove account',
onPress: () => {
store.session.removeAccount(handle)
Toast.show('Account removed from quick access')
},
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: 'trash',
},
},
]
return (
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown
testID="accountSettingsDropdownBtn"
items={items}
accessibilityLabel="Account options"
accessibilityHint="">
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</NativeDropdown>
</Pressable>
)
}
const EmailConfirmationNotice = observer( const EmailConfirmationNotice = observer(
function EmailConfirmationNoticeImpl() { function EmailConfirmationNoticeImpl() {
const pal = usePalette('default') const pal = usePalette('default')

View File

@ -75,6 +75,9 @@ export const BottomBar = observer(function BottomBarImpl({
const onPressProfile = React.useCallback(() => { const onPressProfile = React.useCallback(() => {
onPressTab('MyProfile') onPressTab('MyProfile')
}, [onPressTab]) }, [onPressTab])
const onLongPressProfile = React.useCallback(() => {
store.shell.openModal({name: 'switch-account'})
}, [store])
return ( return (
<Animated.View <Animated.View
@ -202,6 +205,7 @@ export const BottomBar = observer(function BottomBarImpl({
</View> </View>
} }
onPress={onPressProfile} onPress={onPressProfile}
onLongPress={onLongPressProfile}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityLabel="Profile" accessibilityLabel="Profile"
accessibilityHint="" accessibilityHint=""