Move global "Sign out" out of the current account row (#4941)

* Rename logout to logoutEveryAccount

* Add logoutCurrentAccount()

* Make all "Log out" buttons refer to current account

Each of these usages is completely contextual and refers to a specific account.

* Add Sign out of all accounts to Settings

* Move single account Sign Out below as well

* Prompt on account removal

* Add Other Accounts header to reduce ambiguity

* Spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
dan 2024-08-15 20:58:13 +01:00 committed by GitHub
parent f3b57dd456
commit b6e515c664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 247 additions and 77 deletions

View File

@ -14,6 +14,7 @@ export type LogEvents = {
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
scope: 'current' | 'every'
}
'notifications:openApp': {}
'notifications:request': {

View File

@ -38,7 +38,7 @@ export function Deactivated() {
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi()
const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>()
@ -72,8 +72,8 @@ export function Deactivated() {
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
logout('Deactivated')
}, [logout])
logoutCurrentAccount('Deactivated')
}, [logoutCurrentAccount])
const handleActivate = React.useCallback(async () => {
try {

View File

@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const agent = useAgent()
const {logout} = useSessionApi()
const {logoutCurrentAccount} = useSessionApi()
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>()
@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
setPending(true)
await agent.com.atproto.server.deactivateAccount({})
control.close(() => {
logout('Deactivated')
logoutCurrentAccount('Deactivated')
})
} catch (e: any) {
switch (e.message) {
@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
} finally {
setPending(false)
}
}, [agent, control, logout, _, setPending])
}, [agent, control, logoutCurrentAccount, _, setPending])
return (
<>

View File

@ -23,7 +23,7 @@ export function SignupQueued() {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi()
const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
@ -153,7 +153,7 @@ export function SignupQueued() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
@ -182,7 +182,7 @@ export function SignupQueued() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>

View File

@ -76,7 +76,7 @@ describe('session', () => {
state = run(state, [
{
type: 'logged-out',
type: 'logged-out-every-account',
},
])
// Should keep the account but clear out the tokens.
@ -372,7 +372,7 @@ describe('session', () => {
state = run(state, [
{
// Log everyone out.
type: 'logged-out',
type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(3)
@ -466,7 +466,7 @@ describe('session', () => {
state = run(state, [
{
type: 'logged-out',
type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(1)
@ -674,6 +674,103 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe(undefined)
})
it('can log out of the current account', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
accessJwt: 'alice-access-jwt-1',
refreshJwt: 'alice-refresh-jwt-1',
}
state = run(state, [
{
type: 'switched-to-account',
newAgent: agent1,
newAccount: agentToSessionAccountOrThrow(agent1),
},
])
expect(state.accounts.length).toBe(1)
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
accessJwt: 'bob-access-jwt-1',
refreshJwt: 'bob-refresh-jwt-1',
}
state = run(state, [
{
type: 'switched-to-account',
newAgent: agent2,
newAccount: agentToSessionAccountOrThrow(agent2),
},
])
expect(state.accounts.length).toBe(2)
expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1')
expect(state.currentAgentState.did).toBe('bob-did')
state = run(state, [
{
type: 'logged-out-current-account',
},
])
expect(state.accounts.length).toBe(2)
expect(state.accounts[0].accessJwt).toBe(undefined)
expect(state.accounts[0].refreshJwt).toBe(undefined)
expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1')
expect(state.currentAgentState.did).toBe(undefined)
expect(printState(state)).toMatchInlineSnapshot(`
{
"accounts": [
{
"accessJwt": undefined,
"active": true,
"did": "bob-did",
"email": undefined,
"emailAuthFactor": false,
"emailConfirmed": false,
"handle": "bob.test",
"pdsUrl": undefined,
"refreshJwt": undefined,
"service": "https://bob.com/",
"signupQueued": false,
"status": undefined,
},
{
"accessJwt": "alice-access-jwt-1",
"active": true,
"did": "alice-did",
"email": undefined,
"emailAuthFactor": false,
"emailConfirmed": false,
"handle": "alice.test",
"pdsUrl": undefined,
"refreshJwt": "alice-refresh-jwt-1",
"service": "https://alice.com/",
"signupQueued": false,
"status": undefined,
},
],
"currentAgentState": {
"agent": {
"service": "https://public.api.bsky.app/",
},
"did": undefined,
},
"needsPersist": true,
}
`)
})
it('updates stored account with refreshed tokens', () => {
let state = getInitialState([])

View File

@ -35,7 +35,8 @@ const AgentContext = React.createContext<BskyAgent | null>(null)
const ApiContext = React.createContext<SessionApiContext>({
createAccount: async () => {},
login: async () => {},
logout: async () => {},
logoutCurrentAccount: async () => {},
logoutEveryAccount: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
})
@ -115,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask],
)
const logout = React.useCallback<SessionApiContext['logout']>(
const logoutCurrentAccount = React.useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
type: 'logged-out',
type: 'logged-out-current-account',
})
logEvent('account:loggedOut', {logContext})
logEvent('account:loggedOut', {logContext, scope: 'current'})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
)
const logoutEveryAccount = React.useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
type: 'logged-out-every-account',
})
logEvent('account:loggedOut', {logContext, scope: 'every'})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
@ -230,11 +248,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
createAccount,
login,
logout,
logoutCurrentAccount,
logoutEveryAccount,
resumeSession,
removeAccount,
}),
[createAccount, login, logout, resumeSession, removeAccount],
[
createAccount,
login,
logoutCurrentAccount,
logoutEveryAccount,
resumeSession,
removeAccount,
],
)
// @ts-ignore

View File

@ -42,7 +42,10 @@ export type Action =
accountDid: string
}
| {
type: 'logged-out'
type: 'logged-out-current-account'
}
| {
type: 'logged-out-every-account'
}
| {
type: 'synced-accounts'
@ -138,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true,
}
}
case 'logged-out': {
case 'logged-out-current-account': {
const {currentAgentState} = state
return {
accounts: state.accounts.map(a =>
a.did === currentAgentState.did
? {
...a,
refreshJwt: undefined,
accessJwt: undefined,
}
: a,
),
currentAgentState: createPublicAgentState(),
needsPersist: true,
}
}
case 'logged-out-every-account': {
return {
accounts: state.accounts.map(a => ({
...a,

View File

@ -29,12 +29,12 @@ export type SessionApiContext = {
},
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void>
/**
* A full logout. Clears the `currentAccount` from session, AND removes
* access tokens from all accounts, so that returning as any user will
* require a full login.
*/
logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void
logoutCurrentAccount: (
logContext: LogEvents['account:loggedOut']['logContext'],
) => void
logoutEveryAccount: (
logContext: LogEvents['account:loggedOut']['logContext'],
) => void
resumeSession: (account: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void
}

View File

@ -20,7 +20,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() {
const queryClient = useQueryClient()
const {logout, login} = useSessionApi()
const {logoutEveryAccount, login} = useSessionApi()
const {openModal} = useModalControls()
const onboardingDispatch = useOnboardingDispatch()
const {setShowLoggedOut} = useLoggedOutViewControls()
@ -60,7 +60,7 @@ export function TestCtrls() {
/>
<Pressable
testID="e2eSignOut"
onPress={() => logout('Settings')}
onPress={() => logoutEveryAccount('Settings')}
accessibilityRole="button"
style={BTN}
/>

View File

@ -4,26 +4,27 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
import * as Toast from '../../com/util/Toast'
import {useSessionApi, SessionAccount} from '#/state/session'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {SessionAccount, useSessionApi} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '../../com/util/Toast'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
export function AccountDropdownBtn({account}: {account: SessionAccount}) {
const pal = usePalette('default')
const {removeAccount} = useSessionApi()
const removePromptControl = useDialogControl()
const {_} = useLingui()
const items: DropdownItem[] = [
{
label: _(msg`Remove account`),
onPress: () => {
removeAccount(account)
Toast.show(_(msg`Account removed from quick access`))
},
onPress: removePromptControl.open,
icon: {
ios: {
name: 'trash',
@ -34,17 +35,32 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
},
]
return (
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown
testID="accountSettingsDropdownBtn"
items={items}
accessibilityLabel={_(msg`Account options`)}
accessibilityHint="">
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</NativeDropdown>
</Pressable>
<>
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown
testID="accountSettingsDropdownBtn"
items={items}
accessibilityLabel={_(msg`Account options`)}
accessibilityHint="">
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</NativeDropdown>
</Pressable>
<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 File

@ -57,7 +57,6 @@ import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateA
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {navigate, resetToTab} from '#/Navigation'
import {Email2FAToggle} from './Email2FAToggle'
import {ExportCarDialog} from './ExportCarDialog'
@ -77,7 +76,6 @@ function SettingsAccountCard({
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const {logout} = useSessionApi()
const {data: profile} = useProfileQuery({did: account.did})
const isCurrentAccount = account.did === currentAccount?.did
@ -103,31 +101,7 @@ function SettingsAccountCard({
{account.handle}
</Text>
</View>
{isCurrentAccount ? (
<TouchableOpacity
testID="signOutBtn"
onPress={() => {
if (isNative) {
logout('Settings')
resetToTab('HomeTab')
} else {
navigate('Home').then(() => {
logout('Settings')
})
}
}}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out`)}
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
activeOpacity={0.8}>
<Text type="lg" style={pal.link}>
<Trans>Sign out</Trans>
</Text>
</TouchableOpacity>
) : (
<AccountDropdownBtn account={account} />
)}
<AccountDropdownBtn account={account} />
</View>
)
@ -173,6 +147,7 @@ export function SettingsScreen({}: Props) {
const {accounts, currentAccount} = useSession()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const {setShowLoggedOut} = useLoggedOutViewControls()
const {logoutEveryAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const birthdayControl = useDialogControl()
@ -237,6 +212,10 @@ export function SettingsScreen({}: Props) {
openModal({name: 'delete-account'})
}, [openModal])
const onPressLogoutEveryAccount = React.useCallback(() => {
logoutEveryAccount('Settings')
}, [logoutEveryAccount])
const onPressResetPreferences = React.useCallback(async () => {
clearPreferences()
}, [clearPreferences])
@ -394,6 +373,15 @@ export function SettingsScreen({}: Props) {
) : null}
<View pointerEvents={pendingDid ? 'none' : 'auto'}>
{accounts.length > 1 && (
<View style={[s.flexRow, styles.heading, a.mt_sm]}>
<Text type="xl-bold" style={pal.text} numberOfLines={1}>
<Trans>Other accounts</Trans>
</Text>
<View style={s.flex1} />
</View>
)}
{accounts
.filter(a => a.did !== currentAccount?.did)
.map(account => (
@ -422,6 +410,29 @@ export function SettingsScreen({}: Props) {
<Trans>Add account</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.linkCard, pal.view]}
onPress={
isSwitchingAccounts ? undefined : onPressLogoutEveryAccount
}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out of all accounts`)}
accessibilityHint={undefined}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="arrow-right-from-bracket"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
{accounts.length > 1 ? (
<Trans>Sign out of all accounts</Trans>
) : (
<Trans>Sign out</Trans>
)}
</Text>
</TouchableOpacity>
</View>
<View style={styles.spacer20} />