Bluesky app fork with some witchin' additions 💫

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>

authored by danabra.mov

Paul Frazee and committed by
GitHub
b6e515c6 f3b57dd4

+246 -76
+1
src/lib/statsig/events.ts
··· 14 14 } 15 15 'account:loggedOut': { 16 16 logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' 17 + scope: 'current' | 'every' 17 18 } 18 19 'notifications:openApp': {} 19 20 'notifications:request': {
+3 -3
src/screens/Deactivated.tsx
··· 38 38 const {setShowLoggedOut} = useLoggedOutViewControls() 39 39 const hasOtherAccounts = accounts.length > 1 40 40 const setMinimalShellMode = useSetMinimalShellMode() 41 - const {logout} = useSessionApi() 41 + const {logoutCurrentAccount} = useSessionApi() 42 42 const agent = useAgent() 43 43 const [pending, setPending] = React.useState(false) 44 44 const [error, setError] = React.useState<string | undefined>() ··· 72 72 // So we change the URL ourselves. The navigator will pick it up on remount. 73 73 history.pushState(null, '', '/') 74 74 } 75 - logout('Deactivated') 76 - }, [logout]) 75 + logoutCurrentAccount('Deactivated') 76 + }, [logoutCurrentAccount]) 77 77 78 78 const handleActivate = React.useCallback(async () => { 79 79 try {
+3 -3
src/screens/Settings/components/DeactivateAccountDialog.tsx
··· 35 35 const {gtMobile} = useBreakpoints() 36 36 const {_} = useLingui() 37 37 const agent = useAgent() 38 - const {logout} = useSessionApi() 38 + const {logoutCurrentAccount} = useSessionApi() 39 39 const [pending, setPending] = React.useState(false) 40 40 const [error, setError] = React.useState<string | undefined>() 41 41 ··· 44 44 setPending(true) 45 45 await agent.com.atproto.server.deactivateAccount({}) 46 46 control.close(() => { 47 - logout('Deactivated') 47 + logoutCurrentAccount('Deactivated') 48 48 }) 49 49 } catch (e: any) { 50 50 switch (e.message) { ··· 66 66 } finally { 67 67 setPending(false) 68 68 } 69 - }, [agent, control, logout, _, setPending]) 69 + }, [agent, control, logoutCurrentAccount, _, setPending]) 70 70 71 71 return ( 72 72 <>
+3 -3
src/screens/SignupQueued.tsx
··· 23 23 const insets = useSafeAreaInsets() 24 24 const {gtMobile} = useBreakpoints() 25 25 const onboardingDispatch = useOnboardingDispatch() 26 - const {logout} = useSessionApi() 26 + const {logoutCurrentAccount} = useSessionApi() 27 27 const agent = useAgent() 28 28 29 29 const [isProcessing, setProcessing] = React.useState(false) ··· 153 153 variant="ghost" 154 154 size="large" 155 155 label={_(msg`Log out`)} 156 - onPress={() => logout('SignupQueued')}> 156 + onPress={() => logoutCurrentAccount('SignupQueued')}> 157 157 <ButtonText style={[{color: t.palette.primary_500}]}> 158 158 <Trans>Log out</Trans> 159 159 </ButtonText> ··· 182 182 variant="ghost" 183 183 size="large" 184 184 label={_(msg`Log out`)} 185 - onPress={() => logout('SignupQueued')}> 185 + onPress={() => logoutCurrentAccount('SignupQueued')}> 186 186 <ButtonText style={[{color: t.palette.primary_500}]}> 187 187 <Trans>Log out</Trans> 188 188 </ButtonText>
+100 -3
src/state/session/__tests__/session-test.ts
··· 76 76 77 77 state = run(state, [ 78 78 { 79 - type: 'logged-out', 79 + type: 'logged-out-every-account', 80 80 }, 81 81 ]) 82 82 // Should keep the account but clear out the tokens. ··· 372 372 state = run(state, [ 373 373 { 374 374 // Log everyone out. 375 - type: 'logged-out', 375 + type: 'logged-out-every-account', 376 376 }, 377 377 ]) 378 378 expect(state.accounts.length).toBe(3) ··· 466 466 467 467 state = run(state, [ 468 468 { 469 - type: 'logged-out', 469 + type: 'logged-out-every-account', 470 470 }, 471 471 ]) 472 472 expect(state.accounts.length).toBe(1) ··· 672 672 ]) 673 673 expect(state.accounts.length).toBe(0) 674 674 expect(state.currentAgentState.did).toBe(undefined) 675 + }) 676 + 677 + it('can log out of the current account', () => { 678 + let state = getInitialState([]) 679 + 680 + const agent1 = new BskyAgent({service: 'https://alice.com'}) 681 + agent1.sessionManager.session = { 682 + active: true, 683 + did: 'alice-did', 684 + handle: 'alice.test', 685 + accessJwt: 'alice-access-jwt-1', 686 + refreshJwt: 'alice-refresh-jwt-1', 687 + } 688 + state = run(state, [ 689 + { 690 + type: 'switched-to-account', 691 + newAgent: agent1, 692 + newAccount: agentToSessionAccountOrThrow(agent1), 693 + }, 694 + ]) 695 + expect(state.accounts.length).toBe(1) 696 + expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') 697 + expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') 698 + expect(state.currentAgentState.did).toBe('alice-did') 699 + 700 + const agent2 = new BskyAgent({service: 'https://bob.com'}) 701 + agent2.sessionManager.session = { 702 + active: true, 703 + did: 'bob-did', 704 + handle: 'bob.test', 705 + accessJwt: 'bob-access-jwt-1', 706 + refreshJwt: 'bob-refresh-jwt-1', 707 + } 708 + state = run(state, [ 709 + { 710 + type: 'switched-to-account', 711 + newAgent: agent2, 712 + newAccount: agentToSessionAccountOrThrow(agent2), 713 + }, 714 + ]) 715 + expect(state.accounts.length).toBe(2) 716 + expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1') 717 + expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1') 718 + expect(state.currentAgentState.did).toBe('bob-did') 719 + 720 + state = run(state, [ 721 + { 722 + type: 'logged-out-current-account', 723 + }, 724 + ]) 725 + expect(state.accounts.length).toBe(2) 726 + expect(state.accounts[0].accessJwt).toBe(undefined) 727 + expect(state.accounts[0].refreshJwt).toBe(undefined) 728 + expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1') 729 + expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1') 730 + expect(state.currentAgentState.did).toBe(undefined) 731 + expect(printState(state)).toMatchInlineSnapshot(` 732 + { 733 + "accounts": [ 734 + { 735 + "accessJwt": undefined, 736 + "active": true, 737 + "did": "bob-did", 738 + "email": undefined, 739 + "emailAuthFactor": false, 740 + "emailConfirmed": false, 741 + "handle": "bob.test", 742 + "pdsUrl": undefined, 743 + "refreshJwt": undefined, 744 + "service": "https://bob.com/", 745 + "signupQueued": false, 746 + "status": undefined, 747 + }, 748 + { 749 + "accessJwt": "alice-access-jwt-1", 750 + "active": true, 751 + "did": "alice-did", 752 + "email": undefined, 753 + "emailAuthFactor": false, 754 + "emailConfirmed": false, 755 + "handle": "alice.test", 756 + "pdsUrl": undefined, 757 + "refreshJwt": "alice-refresh-jwt-1", 758 + "service": "https://alice.com/", 759 + "signupQueued": false, 760 + "status": undefined, 761 + }, 762 + ], 763 + "currentAgentState": { 764 + "agent": { 765 + "service": "https://public.api.bsky.app/", 766 + }, 767 + "did": undefined, 768 + }, 769 + "needsPersist": true, 770 + } 771 + `) 675 772 }) 676 773 677 774 it('updates stored account with refreshed tokens', () => {
+32 -6
src/state/session/index.tsx
··· 35 35 const ApiContext = React.createContext<SessionApiContext>({ 36 36 createAccount: async () => {}, 37 37 login: async () => {}, 38 - logout: async () => {}, 38 + logoutCurrentAccount: async () => {}, 39 + logoutEveryAccount: async () => {}, 39 40 resumeSession: async () => {}, 40 41 removeAccount: () => {}, 41 42 }) ··· 115 116 [onAgentSessionChange, cancelPendingTask], 116 117 ) 117 118 118 - const logout = React.useCallback<SessionApiContext['logout']>( 119 + const logoutCurrentAccount = React.useCallback< 120 + SessionApiContext['logoutEveryAccount'] 121 + >( 119 122 logContext => { 120 123 addSessionDebugLog({type: 'method:start', method: 'logout'}) 121 124 cancelPendingTask() 122 125 dispatch({ 123 - type: 'logged-out', 126 + type: 'logged-out-current-account', 124 127 }) 125 - logEvent('account:loggedOut', {logContext}) 128 + logEvent('account:loggedOut', {logContext, scope: 'current'}) 129 + addSessionDebugLog({type: 'method:end', method: 'logout'}) 130 + }, 131 + [cancelPendingTask], 132 + ) 133 + 134 + const logoutEveryAccount = React.useCallback< 135 + SessionApiContext['logoutEveryAccount'] 136 + >( 137 + logContext => { 138 + addSessionDebugLog({type: 'method:start', method: 'logout'}) 139 + cancelPendingTask() 140 + dispatch({ 141 + type: 'logged-out-every-account', 142 + }) 143 + logEvent('account:loggedOut', {logContext, scope: 'every'}) 126 144 addSessionDebugLog({type: 'method:end', method: 'logout'}) 127 145 }, 128 146 [cancelPendingTask], ··· 230 248 () => ({ 231 249 createAccount, 232 250 login, 233 - logout, 251 + logoutCurrentAccount, 252 + logoutEveryAccount, 234 253 resumeSession, 235 254 removeAccount, 236 255 }), 237 - [createAccount, login, logout, resumeSession, removeAccount], 256 + [ 257 + createAccount, 258 + login, 259 + logoutCurrentAccount, 260 + logoutEveryAccount, 261 + resumeSession, 262 + removeAccount, 263 + ], 238 264 ) 239 265 240 266 // @ts-ignore
+21 -2
src/state/session/reducer.ts
··· 42 42 accountDid: string 43 43 } 44 44 | { 45 - type: 'logged-out' 45 + type: 'logged-out-current-account' 46 + } 47 + | { 48 + type: 'logged-out-every-account' 46 49 } 47 50 | { 48 51 type: 'synced-accounts' ··· 138 141 needsPersist: true, 139 142 } 140 143 } 141 - case 'logged-out': { 144 + case 'logged-out-current-account': { 145 + const {currentAgentState} = state 146 + return { 147 + accounts: state.accounts.map(a => 148 + a.did === currentAgentState.did 149 + ? { 150 + ...a, 151 + refreshJwt: undefined, 152 + accessJwt: undefined, 153 + } 154 + : a, 155 + ), 156 + currentAgentState: createPublicAgentState(), 157 + needsPersist: true, 158 + } 159 + } 160 + case 'logged-out-every-account': { 142 161 return { 143 162 accounts: state.accounts.map(a => ({ 144 163 ...a,
+6 -6
src/state/session/types.ts
··· 29 29 }, 30 30 logContext: LogEvents['account:loggedIn']['logContext'], 31 31 ) => Promise<void> 32 - /** 33 - * A full logout. Clears the `currentAccount` from session, AND removes 34 - * access tokens from all accounts, so that returning as any user will 35 - * require a full login. 36 - */ 37 - logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void 32 + logoutCurrentAccount: ( 33 + logContext: LogEvents['account:loggedOut']['logContext'], 34 + ) => void 35 + logoutEveryAccount: ( 36 + logContext: LogEvents['account:loggedOut']['logContext'], 37 + ) => void 38 38 resumeSession: (account: SessionAccount) => Promise<void> 39 39 removeAccount: (account: SessionAccount) => void 40 40 }
+2 -2
src/view/com/testing/TestCtrls.e2e.tsx
··· 20 20 21 21 export function TestCtrls() { 22 22 const queryClient = useQueryClient() 23 - const {logout, login} = useSessionApi() 23 + const {logoutEveryAccount, login} = useSessionApi() 24 24 const {openModal} = useModalControls() 25 25 const onboardingDispatch = useOnboardingDispatch() 26 26 const {setShowLoggedOut} = useLoggedOutViewControls() ··· 60 60 /> 61 61 <Pressable 62 62 testID="e2eSignOut" 63 - onPress={() => logout('Settings')} 63 + onPress={() => logoutEveryAccount('Settings')} 64 64 accessibilityRole="button" 65 65 style={BTN} 66 66 />
+37 -21
src/view/com/util/AccountDropdownBtn.tsx
··· 4 4 FontAwesomeIcon, 5 5 FontAwesomeIconStyle, 6 6 } from '@fortawesome/react-native-fontawesome' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + 10 + import {SessionAccount, useSessionApi} from '#/state/session' 11 + import {usePalette} from 'lib/hooks/usePalette' 7 12 import {s} from 'lib/styles' 8 - import {usePalette} from 'lib/hooks/usePalette' 13 + import {useDialogControl} from '#/components/Dialog' 14 + import * as Prompt from '#/components/Prompt' 15 + import * as Toast from '../../com/util/Toast' 9 16 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' 10 - import * as Toast from '../../com/util/Toast' 11 - import {useSessionApi, SessionAccount} from '#/state/session' 12 - import {useLingui} from '@lingui/react' 13 - import {msg} from '@lingui/macro' 14 17 15 18 export function AccountDropdownBtn({account}: {account: SessionAccount}) { 16 19 const pal = usePalette('default') 17 20 const {removeAccount} = useSessionApi() 21 + const removePromptControl = useDialogControl() 18 22 const {_} = useLingui() 19 23 20 24 const items: DropdownItem[] = [ 21 25 { 22 26 label: _(msg`Remove account`), 23 - onPress: () => { 24 - removeAccount(account) 25 - Toast.show(_(msg`Account removed from quick access`)) 26 - }, 27 + onPress: removePromptControl.open, 27 28 icon: { 28 29 ios: { 29 30 name: 'trash', ··· 34 35 }, 35 36 ] 36 37 return ( 37 - <Pressable accessibilityRole="button" style={s.pl10}> 38 - <NativeDropdown 39 - testID="accountSettingsDropdownBtn" 40 - items={items} 41 - accessibilityLabel={_(msg`Account options`)} 42 - accessibilityHint=""> 43 - <FontAwesomeIcon 44 - icon="ellipsis-h" 45 - style={pal.textLight as FontAwesomeIconStyle} 46 - /> 47 - </NativeDropdown> 48 - </Pressable> 38 + <> 39 + <Pressable accessibilityRole="button" style={s.pl10}> 40 + <NativeDropdown 41 + testID="accountSettingsDropdownBtn" 42 + items={items} 43 + accessibilityLabel={_(msg`Account options`)} 44 + accessibilityHint=""> 45 + <FontAwesomeIcon 46 + icon="ellipsis-h" 47 + style={pal.textLight as FontAwesomeIconStyle} 48 + /> 49 + </NativeDropdown> 50 + </Pressable> 51 + <Prompt.Basic 52 + control={removePromptControl} 53 + title={_(msg`Remove from quick access?`)} 54 + description={_( 55 + msg`This will remove @${account.handle} from the quick access list.`, 56 + )} 57 + onConfirm={() => { 58 + removeAccount(account) 59 + Toast.show(_(msg`Account removed from quick access`)) 60 + }} 61 + confirmButtonCta={_(msg`Remove`)} 62 + confirmButtonColor="negative" 63 + /> 64 + </> 49 65 ) 50 66 }
+38 -27
src/view/screens/Settings/index.tsx
··· 57 57 import {atoms as a, useTheme} from '#/alf' 58 58 import {useDialogControl} from '#/components/Dialog' 59 59 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 60 - import {navigate, resetToTab} from '#/Navigation' 61 60 import {Email2FAToggle} from './Email2FAToggle' 62 61 import {ExportCarDialog} from './ExportCarDialog' 63 62 ··· 77 76 const {_} = useLingui() 78 77 const t = useTheme() 79 78 const {currentAccount} = useSession() 80 - const {logout} = useSessionApi() 81 79 const {data: profile} = useProfileQuery({did: account.did}) 82 80 const isCurrentAccount = account.did === currentAccount?.did 83 81 ··· 103 101 {account.handle} 104 102 </Text> 105 103 </View> 106 - 107 - {isCurrentAccount ? ( 108 - <TouchableOpacity 109 - testID="signOutBtn" 110 - onPress={() => { 111 - if (isNative) { 112 - logout('Settings') 113 - resetToTab('HomeTab') 114 - } else { 115 - navigate('Home').then(() => { 116 - logout('Settings') 117 - }) 118 - } 119 - }} 120 - accessibilityRole="button" 121 - accessibilityLabel={_(msg`Sign out`)} 122 - accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`} 123 - activeOpacity={0.8}> 124 - <Text type="lg" style={pal.link}> 125 - <Trans>Sign out</Trans> 126 - </Text> 127 - </TouchableOpacity> 128 - ) : ( 129 - <AccountDropdownBtn account={account} /> 130 - )} 104 + <AccountDropdownBtn account={account} /> 131 105 </View> 132 106 ) 133 107 ··· 173 147 const {accounts, currentAccount} = useSession() 174 148 const {mutate: clearPreferences} = useClearPreferencesMutation() 175 149 const {setShowLoggedOut} = useLoggedOutViewControls() 150 + const {logoutEveryAccount} = useSessionApi() 176 151 const closeAllActiveElements = useCloseAllActiveElements() 177 152 const exportCarControl = useDialogControl() 178 153 const birthdayControl = useDialogControl() ··· 236 211 const onPressDeleteAccount = React.useCallback(() => { 237 212 openModal({name: 'delete-account'}) 238 213 }, [openModal]) 214 + 215 + const onPressLogoutEveryAccount = React.useCallback(() => { 216 + logoutEveryAccount('Settings') 217 + }, [logoutEveryAccount]) 239 218 240 219 const onPressResetPreferences = React.useCallback(async () => { 241 220 clearPreferences() ··· 394 373 ) : null} 395 374 396 375 <View pointerEvents={pendingDid ? 'none' : 'auto'}> 376 + {accounts.length > 1 && ( 377 + <View style={[s.flexRow, styles.heading, a.mt_sm]}> 378 + <Text type="xl-bold" style={pal.text} numberOfLines={1}> 379 + <Trans>Other accounts</Trans> 380 + </Text> 381 + <View style={s.flex1} /> 382 + </View> 383 + )} 384 + 397 385 {accounts 398 386 .filter(a => a.did !== currentAccount?.did) 399 387 .map(account => ( ··· 420 408 </View> 421 409 <Text type="lg" style={pal.text}> 422 410 <Trans>Add account</Trans> 411 + </Text> 412 + </TouchableOpacity> 413 + 414 + <TouchableOpacity 415 + style={[styles.linkCard, pal.view]} 416 + onPress={ 417 + isSwitchingAccounts ? undefined : onPressLogoutEveryAccount 418 + } 419 + accessibilityRole="button" 420 + accessibilityLabel={_(msg`Sign out of all accounts`)} 421 + accessibilityHint={undefined}> 422 + <View style={[styles.iconContainer, pal.btn]}> 423 + <FontAwesomeIcon 424 + icon="arrow-right-from-bracket" 425 + style={pal.text as FontAwesomeIconStyle} 426 + /> 427 + </View> 428 + <Text type="lg" style={pal.text}> 429 + {accounts.length > 1 ? ( 430 + <Trans>Sign out of all accounts</Trans> 431 + ) : ( 432 + <Trans>Sign out</Trans> 433 + )} 423 434 </Text> 424 435 </TouchableOpacity> 425 436 </View>