Bluesky app fork with some witchin' additions 💫

Select account redesign (#8905)

* new designs for select account

* tweak web styles

* add logged out indicator

* unify helper function

* move error, try catch

authored by samuel.fm and committed by

GitHub ad91029f 4791486a

+90 -39
+58 -24
src/components/AccountList.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {useActorStatus} from '#/lib/actor-status' 8 + import {isJwtExpired} from '#/lib/jwt' 8 9 import {sanitizeDisplayName} from '#/lib/strings/display-names' 9 10 import {sanitizeHandle} from '#/lib/strings/handles' 10 11 import {useProfilesQuery} from '#/state/queries/profile' 11 12 import {type SessionAccount, useSession} from '#/state/session' 12 13 import {UserAvatar} from '#/view/com/util/UserAvatar' 13 14 import {atoms as a, useTheme} from '#/alf' 14 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 15 - import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron' 15 + import {Button} from '#/components/Button' 16 + import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 17 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' 18 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 19 + import {Text} from '#/components/Typography' 16 20 import {useSimpleVerificationState} from '#/components/verification' 17 21 import {VerificationCheck} from '#/components/verification/VerificationCheck' 18 - import {Button} from './Button' 19 - import {Text} from './Typography' 20 22 21 23 export function AccountList({ 22 24 onSelectAccount, ··· 44 46 <View 45 47 pointerEvents={pendingDid ? 'none' : 'auto'} 46 48 style={[ 47 - a.rounded_md, 49 + a.rounded_lg, 48 50 a.overflow_hidden, 49 - {borderWidth: 1}, 51 + a.border, 50 52 t.atoms.border_contrast_low, 51 53 ]}> 52 54 {accounts.map(account => ( ··· 58 60 isCurrentAccount={account.did === currentAccount?.did} 59 61 isPendingAccount={account.did === pendingDid} 60 62 /> 61 - <View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} /> 63 + <View style={[a.border_b, t.atoms.border_contrast_low]} /> 62 64 </React.Fragment> 63 65 ))} 64 66 <Button ··· 72 74 a.flex_1, 73 75 a.flex_row, 74 76 a.align_center, 75 - {height: 48}, 77 + a.p_lg, 78 + a.gap_sm, 76 79 (hovered || pressed) && t.atoms.bg_contrast_25, 77 80 ]}> 78 - <Text 81 + <View 79 82 style={[ 80 - a.font_semi_bold, 81 - a.flex_1, 82 - a.flex_row, 83 - a.py_sm, 84 - a.leading_tight, 85 - t.atoms.text_contrast_medium, 86 - {paddingLeft: 56}, 83 + t.atoms.bg_contrast_25, 84 + a.rounded_full, 85 + {width: 48, height: 48}, 86 + a.justify_center, 87 + a.align_center, 88 + (hovered || pressed) && t.atoms.bg_contrast_50, 87 89 ]}> 90 + <PlusIcon style={[t.atoms.text_contrast_low]} size="md" /> 91 + </View> 92 + <Text style={[a.flex_1, a.leading_tight, a.text_md, a.font_medium]}> 88 93 {otherLabel ?? <Trans>Other account</Trans>} 89 94 </Text> 90 - <Chevron size="sm" style={[t.atoms.text, a.mr_md]} /> 95 + <ChevronIcon size="md" style={[t.atoms.text_contrast_low]} /> 91 96 </View> 92 97 )} 93 98 </Button> ··· 116 121 const onPress = useCallback(() => { 117 122 onSelect(account) 118 123 }, [account, onSelect]) 124 + 125 + const isLoggedOut = !account.refreshJwt || isJwtExpired(account.refreshJwt) 119 126 120 127 return ( 121 128 <Button ··· 134 141 a.flex_1, 135 142 a.flex_row, 136 143 a.align_center, 137 - a.px_md, 144 + a.p_lg, 138 145 a.gap_sm, 139 - {height: 56}, 140 146 (hovered || pressed || isPendingAccount) && t.atoms.bg_contrast_25, 141 147 ]}> 142 148 <UserAvatar 143 149 avatar={profile?.avatar} 144 - size={36} 150 + size={48} 145 151 type={profile?.associated?.labeler ? 'labeler' : 'user'} 146 152 live={live} 147 153 hideLiveBadge ··· 151 157 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 152 158 <Text 153 159 emoji 154 - style={[a.font_semi_bold, a.leading_tight]} 160 + style={[a.font_medium, a.leading_tight, a.text_md]} 155 161 numberOfLines={1}> 156 162 {sanitizeDisplayName( 157 163 profile?.displayName || profile?.handle || account.handle, ··· 166 172 </View> 167 173 )} 168 174 </View> 169 - <Text style={[a.leading_tight, t.atoms.text_contrast_medium]}> 175 + <Text 176 + style={[ 177 + a.leading_tight, 178 + t.atoms.text_contrast_medium, 179 + a.text_sm, 180 + ]}> 170 181 {sanitizeHandle(account.handle, '@')} 171 182 </Text> 183 + {isLoggedOut && ( 184 + <Text 185 + style={[ 186 + a.leading_tight, 187 + a.text_xs, 188 + a.italic, 189 + t.atoms.text_contrast_medium, 190 + ]}> 191 + <Trans>Logged out</Trans> 192 + </Text> 193 + )} 172 194 </View> 173 195 174 196 {isCurrentAccount ? ( 175 - <Check size="sm" style={[{color: t.palette.positive_500}]} /> 197 + <View 198 + style={[ 199 + { 200 + width: 20, 201 + height: 20, 202 + backgroundColor: t.palette.positive_500, 203 + }, 204 + a.rounded_full, 205 + a.justify_center, 206 + a.align_center, 207 + ]}> 208 + <CheckIcon size="xs" style={[{color: t.palette.white}]} /> 209 + </View> 176 210 ) : ( 177 - <Chevron size="sm" style={[t.atoms.text]} /> 211 + <ChevronIcon size="md" style={[t.atoms.text_contrast_low]} /> 178 212 )} 179 213 </View> 180 214 )}
+1 -1
src/components/dialogs/SwitchAccount.tsx
··· 41 41 }, [setShowLoggedOut, control]) 42 42 43 43 return ( 44 - <Dialog.Outer control={control}> 44 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 45 45 <Dialog.Handle /> 46 46 <Dialog.ScrollableInner label={_(msg`Switch Account`)}> 47 47 <View style={[a.gap_lg]}>
+23
src/lib/jwt.ts
··· 1 + import {jwtDecode} from 'jwt-decode' 2 + 3 + import {logger} from '#/logger' 4 + 5 + /** 6 + * Simple check if a JWT token has expired. Does *not* validate the token or check for revocation status, 7 + * just checks the expiration time. 8 + * 9 + * @param token The JWT token to check. 10 + * @returns `true` if the token has expired, `false` otherwise. 11 + */ 12 + export function isJwtExpired(token: string) { 13 + try { 14 + const payload = jwtDecode(token) 15 + 16 + if (!payload.exp) return true 17 + const now = Math.floor(Date.now() / 1000) 18 + return now >= payload.exp 19 + } catch { 20 + logger.error(`session: could not decode jwt`) 21 + return true // invalid token or parse error 22 + } 23 + }
+3 -2
src/screens/Login/ChooseAccountForm.tsx
··· 8 8 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 9 9 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 10 10 import * as Toast from '#/view/com/util/Toast' 11 - import {atoms as a} from '#/alf' 11 + import {atoms as a, web} from '#/alf' 12 12 import {AccountList} from '#/components/AccountList' 13 13 import {Button, ButtonText} from '#/components/Button' 14 14 import * as TextField from '#/components/forms/TextField' ··· 74 74 return ( 75 75 <FormContainer 76 76 testID="chooseAccountForm" 77 - titleText={<Trans>Select account</Trans>}> 77 + titleText={<Trans>Select account</Trans>} 78 + style={web([a.py_2xl])}> 78 79 <View> 79 80 <TextField.LabelText> 80 81 <Trans>Sign in as...</Trans>
+5 -12
src/state/session/util.ts
··· 1 1 import {jwtDecode} from 'jwt-decode' 2 2 3 + import {isJwtExpired} from '#/lib/jwt' 3 4 import {hasProp} from '#/lib/type-guards' 4 - import {logger} from '#/logger' 5 5 import * as persisted from '#/state/persisted' 6 6 import {type SessionAccount} from './types' 7 7 ··· 22 22 } 23 23 24 24 export function isSessionExpired(account: SessionAccount) { 25 - try { 26 - if (account.accessJwt) { 27 - const decoded = jwtDecode(account.accessJwt) 28 - if (decoded.exp) { 29 - const didExpire = Date.now() >= decoded.exp * 1000 30 - return didExpire 31 - } 32 - } 33 - } catch (e) { 34 - logger.error(`session: could not decode jwt`) 25 + if (account.accessJwt) { 26 + return isJwtExpired(account.accessJwt) 27 + } else { 28 + return true 35 29 } 36 - return true 37 30 }