Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: sort & hide options for account switcher

- sort options: alphabetical, by date modified, by date added, custom, and a toggle to reverse order
- hidden accounts will only appear in the dropdown on the Settings screen
- fixed bug where accounts with invalid handles don't show an avatar
- added developer mode toggle to Extra settings screen
- added Copy DID menu item to the meatball menus in the Settings page account switcher

+548 -73
+7 -1
src/components/AccountList.tsx
··· 11 11 import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 12 12 import {useProfilesQuery} from '#/state/queries/profile' 13 13 import {type SessionAccount, useSession} from '#/state/session' 14 + import {useSortedAccountItems} from '#/state/session/sorting' 14 15 import {UserAvatar} from '#/view/com/util/UserAvatar' 15 16 import {atoms as a, useTheme} from '#/alf' 16 17 import {Button} from '#/components/Button' ··· 20 21 import {ProfileBadges} from '#/components/ProfileBadges' 21 22 import {Text} from '#/components/Typography' 22 23 import {useActorStatus} from '#/features/liveNow' 24 + import {useHiddenAccountsElsewhere} from '#/storage/hooks/hidden-accounts-elsewhere' 23 25 24 26 export function AccountList({ 25 27 onSelectAccount, ··· 36 38 const t = useTheme() 37 39 const {_} = useLingui() 38 40 const enableSquareButtons = useEnableSquareButtons() 41 + const [, , hiddenDidsSet] = useHiddenAccountsElsewhere() 39 42 const {data: profiles} = useProfilesQuery({ 40 43 handles: accounts.map(acc => acc.did), 41 44 }) 45 + const sortedAccounts = useSortedAccountItems(accounts).filter( 46 + account => !hiddenDidsSet.has(account.did), 47 + ) 42 48 43 49 const onPressAddAccount = useCallback(() => { 44 50 onSelectOther() ··· 53 59 a.border, 54 60 t.atoms.border_contrast_low, 55 61 ]}> 56 - {accounts.map(account => ( 62 + {sortedAccounts.map(account => ( 57 63 <Fragment key={account.did}> 58 64 <AccountItem 59 65 profile={profiles?.profiles.find(p => p.did === account.did)}
+5 -1
src/components/AvatarStack.tsx
··· 95 95 return null 96 96 } 97 97 98 + const orderedProfiles = profiles 99 + .map(did => data?.profiles?.find(profile => profile.did === did)) 100 + .filter(Boolean) 101 + 98 102 return ( 99 103 <AvatarStack 100 104 numPending={profiles.length} 101 - profiles={data?.profiles || []} 105 + profiles={orderedProfiles} 102 106 size={size} 103 107 backgroundColor={backgroundColor} 104 108 />
+16
src/screens/Settings/RunesSettings/ExtraSettings.tsx
··· 25 25 import {Admonition} from '#/components/Admonition' 26 26 import * as Toggle from '#/components/forms/Toggle' 27 27 import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' 28 + import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 28 29 import {Explosion_Stroke2_Corner0_Rounded as ExplosionIcon} from '#/components/icons/Explosion' 29 30 import {Eye_Stroke2_Corner0_Rounded as VisibilityIcon} from '#/components/icons/Eye' 30 31 import {LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon} from '#/components/icons/Heart2' 31 32 import {Lab_Stroke2_Corner0_Rounded as BeakerIcon} from '#/components/icons/Lab' 33 + import {useDevMode} from '#/storage/hooks/dev-mode' 32 34 import {RunesScreenLayout} from './components/RunesScreenLayout' 33 35 34 36 export function RunesExtraSettingsScreen() { ··· 48 50 49 51 const omitViaField = useOmitViaField() 50 52 const setOmitViaField = useSetOmitViaField() 53 + const [devMode, setDevMode] = useDevMode() 51 54 52 55 return ( 53 56 <RunesScreenLayout titleText={l`Extra`}> ··· 123 126 <SettingsList.ItemIcon icon={ExplosionIcon} /> 124 127 <SettingsList.ItemText> 125 128 <Trans>Don't include the 'via' field in own posts</Trans> 129 + </SettingsList.ItemText> 130 + <Toggle.Platform /> 131 + </SettingsList.Item> 132 + </Toggle.Item> 133 + <Toggle.Item 134 + name="dev_mode" 135 + label={l`Developer mode`} 136 + value={devMode} 137 + onChange={value => setDevMode(value)}> 138 + <SettingsList.Item> 139 + <SettingsList.ItemIcon icon={CodeBracketsIcon} /> 140 + <SettingsList.ItemText> 141 + <Trans>Developer mode</Trans> 126 142 </SettingsList.ItemText> 127 143 <Toggle.Platform /> 128 144 </SettingsList.Item>
+331 -45
src/screens/Settings/Settings.tsx
··· 1 1 import {useState} from 'react' 2 2 import {Alert, LayoutAnimation, Pressable, View} from 'react-native' 3 - import {useReducedMotion} from 'react-native-reanimated' 3 + import Animated, { 4 + useAnimatedRef, 5 + useReducedMotion, 6 + useScrollViewOffset, 7 + } from 'react-native-reanimated' 8 + import {setStringAsync} from 'expo-clipboard' 4 9 import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' 5 10 import {Trans, useLingui} from '@lingui/react/macro' 6 11 import {useNavigation} from '@react-navigation/native' ··· 24 29 import {useAgent} from '#/state/session' 25 30 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 26 31 import {pdsAgent} from '#/state/session/agent' 32 + import { 33 + type AccountSortOption, 34 + sortAccountItems, 35 + useAccountSwitcherSortSettings, 36 + } from '#/state/session/sorting' 27 37 import {useOnboardingDispatch} from '#/state/shell' 28 38 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 29 39 import {useCloseAllActiveElements} from '#/state/util' ··· 32 42 import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf' 33 43 import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' 34 44 import {AvatarStackWithFetch} from '#/components/AvatarStack' 35 - import {Button, ButtonText} from '#/components/Button' 45 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 36 46 import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 37 47 import {useDialogControl} from '#/components/Dialog' 38 48 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 49 + import {SortableList} from '#/components/DraggableList' 39 50 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' 51 + import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ReverseIcon} from '#/components/icons/ArrowRotate' 40 52 import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell' 41 53 import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' 42 54 import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' 55 + import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 43 56 import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 44 57 import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts' 45 58 import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 46 59 import {Eclipse_Stroke2_Corner0_Rounded as EclipseIcon} from '#/components/icons/Eclipse' 60 + import {Filter_Stroke2_Corner0_Rounded as SortIcon} from '#/components/icons/Filter' 61 + import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk' 47 62 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 48 63 import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' 49 64 import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' ··· 68 83 import {useActorStatus} from '#/features/liveNow' 69 84 import {device, useStorage} from '#/storage' 70 85 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged' 86 + import {useDevMode} from '#/storage/hooks/dev-mode' 87 + import {useHiddenAccountsElsewhere} from '#/storage/hooks/hidden-accounts-elsewhere' 71 88 72 89 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> 90 + type AccountListItem = { 91 + account: SessionAccount 92 + profile?: AppBskyActorDefs.ProfileViewDetailed 93 + } 94 + 73 95 export function SettingsScreen({}: Props) { 74 96 const ax = useAnalytics() 75 97 const {t: l} = useLingui() 98 + const t = useTheme() 76 99 const reducedMotion = useReducedMotion() 77 - const {logoutEveryAccount} = useSessionApi() 100 + const scrollRef = useAnimatedRef<Animated.ScrollView>() 101 + const scrollOffset = useScrollViewOffset(scrollRef) 102 + const {logoutEveryAccount, reorderAccounts} = useSessionApi() 78 103 const {accounts, currentAccount} = useSession() 79 104 const switchAccountControl = useDialogControl() 80 105 const signOutPromptControl = Prompt.usePromptControl() ··· 82 107 const {data: otherProfiles} = useProfilesQuery({ 83 108 handles: accounts 84 109 .filter(acc => acc.did !== currentAccount?.did) 85 - .map(acc => acc.handle), 110 + .map(acc => acc.did), 86 111 }) 87 112 const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() 113 + const enableSquareButtons = useEnableSquareButtons() 88 114 const [showAccounts, setShowAccounts] = useState(false) 115 + const [isDraggingAccounts, setIsDraggingAccounts] = useState(false) 116 + const [isCustomSortEditing, setIsCustomSortEditing] = useState(false) 117 + const [customAccountsDraft, setCustomAccountsDraft] = useState< 118 + AccountListItem[] 119 + >([]) 120 + const {sortBy, setSortBy, reverse, setReverse} = 121 + useAccountSwitcherSortSettings() 122 + const [, , hiddenDidsSet] = useHiddenAccountsElsewhere() 89 123 const [showDevOptions, setShowDevOptions] = useState(false) 90 124 const findContactsEnabled = 91 125 useIsFindContactsFeatureEnabledBasedOnGeolocation() 126 + const allAccounts = accounts.map(account => ({ 127 + account, 128 + profile: 129 + account.did === currentAccount?.did 130 + ? profile 131 + : otherProfiles?.profiles?.find(p => p.did === account.did), 132 + })) 133 + const otherAccounts = allAccounts.filter( 134 + item => item.account.did !== currentAccount?.did, 135 + ) 136 + const displayedAccounts = isCustomSortEditing 137 + ? customAccountsDraft 138 + : sortAccountItems(otherAccounts, sortBy, reverse) 139 + 140 + const onSelectAccountsSort = (nextSortBy: AccountSortOption) => { 141 + if (nextSortBy === 'custom') { 142 + setCustomAccountsDraft(sortAccountItems(allAccounts, sortBy, reverse)) 143 + setIsCustomSortEditing(true) 144 + return 145 + } 146 + setSortBy(nextSortBy) 147 + setIsCustomSortEditing(false) 148 + setCustomAccountsDraft([]) 149 + } 150 + 151 + const onToggleReverseAccounts = () => { 152 + setReverse(!reverse) 153 + if (isCustomSortEditing) { 154 + setCustomAccountsDraft(prev => [...prev].reverse()) 155 + } 156 + } 157 + 158 + const onCancelCustomSort = () => { 159 + setIsCustomSortEditing(false) 160 + setCustomAccountsDraft([]) 161 + } 162 + 163 + const onSaveCustomSort = () => { 164 + const orderedAccounts = reverse 165 + ? [...customAccountsDraft].reverse() 166 + : customAccountsDraft 167 + reorderAccounts(orderedAccounts.map(item => item.account)) 168 + setSortBy('custom') 169 + setIsCustomSortEditing(false) 170 + setCustomAccountsDraft([]) 171 + } 92 172 93 173 return ( 94 174 <Layout.Screen> ··· 101 181 </Layout.Header.Content> 102 182 <Layout.Header.Slot /> 103 183 </Layout.Header.Outer> 104 - <Layout.Content> 184 + <Layout.Content ref={scrollRef} scrollEnabled={!isDraggingAccounts}> 105 185 <SettingsList.Container> 106 186 <AgeAssuranceDismissibleNotice style={[a.px_lg, a.pt_xs, a.pb_xl]} /> 107 187 ··· 119 199 </View> 120 200 {accounts.length > 1 ? ( 121 201 <> 122 - <SettingsList.PressableItem 123 - label={l`Switch account`} 124 - accessibilityHint={l`Shows other accounts you can switch to`} 125 - onPress={() => { 126 - if (!reducedMotion) { 127 - LayoutAnimation.configureNext( 128 - LayoutAnimation.Presets.easeInEaseOut, 129 - ) 130 - } 131 - setShowAccounts(s => !s) 132 - }}> 133 - <SettingsList.ItemIcon icon={PersonGroupIcon} /> 134 - <SettingsList.ItemText> 135 - <Trans>Switch account</Trans> 136 - </SettingsList.ItemText> 137 - {showAccounts ? ( 138 - <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> 139 - ) : ( 140 - <AvatarStackWithFetch 141 - profiles={accounts 142 - .map(acc => acc.did) 143 - .filter(did => did !== currentAccount?.did) 144 - .slice(0, 5)} 145 - /> 202 + <View style={[a.relative]}> 203 + <SettingsList.PressableItem 204 + label={l`Switch account`} 205 + accessibilityHint={l`Shows other accounts you can switch to`} 206 + onPress={() => { 207 + if (!reducedMotion) { 208 + LayoutAnimation.configureNext( 209 + LayoutAnimation.Presets.easeInEaseOut, 210 + ) 211 + } 212 + if (showAccounts) { 213 + setIsCustomSortEditing(false) 214 + setCustomAccountsDraft([]) 215 + } 216 + setShowAccounts(s => !s) 217 + }}> 218 + <SettingsList.ItemIcon icon={PersonGroupIcon} /> 219 + <SettingsList.ItemText 220 + style={[showAccounts && {paddingRight: 64}]}> 221 + <Trans>Switch account</Trans> 222 + </SettingsList.ItemText> 223 + {showAccounts ? ( 224 + <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" /> 225 + ) : ( 226 + <AvatarStackWithFetch 227 + profiles={sortAccountItems(otherAccounts, sortBy, reverse) 228 + .filter(item => !hiddenDidsSet.has(item.account.did)) 229 + .map(item => item.account.did) 230 + .slice(0, 5)} 231 + /> 232 + )} 233 + </SettingsList.PressableItem> 234 + {showAccounts && ( 235 + <Menu.Root> 236 + <Menu.Trigger label={l`Sort accounts`}> 237 + {({props, state}) => ( 238 + <Pressable 239 + {...props} 240 + style={[ 241 + a.absolute, 242 + {top: 10, right: 48}, 243 + a.p_xs, 244 + enableSquareButtons ? a.rounded_sm : a.rounded_full, 245 + (state.hovered || state.pressed) && 246 + t.atoms.bg_contrast_25, 247 + ]}> 248 + <SortIcon size="md" style={t.atoms.text} /> 249 + </Pressable> 250 + )} 251 + </Menu.Trigger> 252 + <Menu.Outer showCancel> 253 + <Menu.Group> 254 + <Menu.LabelText> 255 + <Trans>Sort accounts</Trans> 256 + </Menu.LabelText> 257 + <Menu.Item 258 + label={l`Alphabetical`} 259 + onPress={() => onSelectAccountsSort('alphabetical')}> 260 + <Menu.ItemRadio 261 + selected={ 262 + (isCustomSortEditing ? 'custom' : sortBy) === 263 + 'alphabetical' 264 + } 265 + /> 266 + <Menu.ItemText> 267 + <Trans>Alphabetical</Trans> 268 + </Menu.ItemText> 269 + </Menu.Item> 270 + <Menu.Item 271 + label={l`By date modified`} 272 + onPress={() => onSelectAccountsSort('dateModified')}> 273 + <Menu.ItemRadio 274 + selected={ 275 + (isCustomSortEditing ? 'custom' : sortBy) === 276 + 'dateModified' 277 + } 278 + /> 279 + <Menu.ItemText> 280 + <Trans>By date modified</Trans> 281 + </Menu.ItemText> 282 + </Menu.Item> 283 + <Menu.Item 284 + label={l`By date added`} 285 + onPress={() => onSelectAccountsSort('dateAdded')}> 286 + <Menu.ItemRadio 287 + selected={ 288 + (isCustomSortEditing ? 'custom' : sortBy) === 289 + 'dateAdded' 290 + } 291 + /> 292 + <Menu.ItemText> 293 + <Trans>By date added</Trans> 294 + </Menu.ItemText> 295 + </Menu.Item> 296 + <Menu.Item 297 + label={l`Custom`} 298 + onPress={() => onSelectAccountsSort('custom')}> 299 + <Menu.ItemRadio 300 + selected={ 301 + (isCustomSortEditing ? 'custom' : sortBy) === 302 + 'custom' 303 + } 304 + /> 305 + <Menu.ItemText> 306 + <Trans>Custom</Trans> 307 + </Menu.ItemText> 308 + </Menu.Item> 309 + </Menu.Group> 310 + <Menu.Divider /> 311 + <Menu.Item 312 + label={l`Reverse order`} 313 + onPress={onToggleReverseAccounts}> 314 + <Menu.ItemRadio selected={reverse} /> 315 + <Menu.ItemText> 316 + <Trans>Reverse order</Trans> 317 + </Menu.ItemText> 318 + <Menu.ItemIcon icon={ReverseIcon} position="right" /> 319 + </Menu.Item> 320 + </Menu.Outer> 321 + </Menu.Root> 146 322 )} 147 - </SettingsList.PressableItem> 323 + </View> 148 324 {showAccounts && ( 149 325 <> 150 326 <SettingsList.Divider /> 151 - {accounts 152 - .filter(acc => acc.did !== currentAccount?.did) 153 - .map(account => ( 327 + {isCustomSortEditing ? ( 328 + <SortableList 329 + data={customAccountsDraft} 330 + keyExtractor={item => item.account.did} 331 + itemHeight={48} 332 + scrollRef={scrollRef} 333 + scrollOffset={scrollOffset} 334 + onDragStart={() => setIsDraggingAccounts(true)} 335 + onDragEnd={() => setIsDraggingAccounts(false)} 336 + onReorder={setCustomAccountsDraft} 337 + renderItem={(item, dragHandle) => ( 338 + <AccountRow 339 + key={item.account.did} 340 + account={item.account} 341 + profile={item.profile} 342 + pendingDid={pendingDid} 343 + disableSwitching 344 + dragHandle={dragHandle} 345 + onPressSwitchAccount={(account, logContext) => 346 + void onPressSwitchAccount(account, logContext) 347 + } 348 + /> 349 + )} 350 + /> 351 + ) : ( 352 + displayedAccounts.map(item => ( 154 353 <AccountRow 155 - key={account.did} 156 - account={account} 157 - profile={otherProfiles?.profiles?.find( 158 - p => p.did === account.did, 159 - )} 354 + key={item.account.did} 355 + account={item.account} 356 + profile={item.profile} 160 357 pendingDid={pendingDid} 161 358 onPressSwitchAccount={(account, logContext) => 162 359 void onPressSwitchAccount(account, logContext) 163 360 } 164 361 /> 165 - ))} 362 + )) 363 + )} 364 + {isCustomSortEditing && ( 365 + <View 366 + style={[a.flex_row, a.gap_sm, a.px_xl, a.pt_md, a.pb_sm]}> 367 + <Button 368 + label={l`Cancel`} 369 + onPress={onCancelCustomSort} 370 + color="secondary" 371 + size="small" 372 + style={[a.flex_1]}> 373 + <ButtonText> 374 + <Trans>Cancel</Trans> 375 + </ButtonText> 376 + </Button> 377 + <Button 378 + label={l`Save changes`} 379 + onPress={onSaveCustomSort} 380 + color="primary" 381 + size="small" 382 + style={[a.flex_1]}> 383 + <ButtonIcon icon={SaveIcon} /> 384 + <ButtonText> 385 + <Trans>Save changes</Trans> 386 + </ButtonText> 387 + </Button> 388 + </View> 389 + )} 166 390 <AddAccountRow /> 167 391 </> 168 392 )} ··· 580 804 profile, 581 805 account, 582 806 pendingDid, 807 + disableSwitching, 808 + dragHandle, 583 809 onPressSwitchAccount, 584 810 }: { 585 811 profile?: AppBskyActorDefs.ProfileViewDetailed 586 812 account: SessionAccount 587 813 pendingDid: string | null 814 + disableSwitching?: boolean 815 + dragHandle?: React.ReactNode 588 816 onPressSwitchAccount: ( 589 817 account: SessionAccount, 590 818 logContext: 'Settings', ··· 597 825 const removePromptControl = Prompt.usePromptControl() 598 826 const {removeAccount} = useSessionApi() 599 827 const {isActive: live} = useActorStatus(profile) 828 + const [devModeEnabled] = useDevMode() 829 + const [hiddenAccountsElsewhere, setHiddenAccountsElsewhere, hiddenDidsSet] = 830 + useHiddenAccountsElsewhere() 600 831 601 832 const enableSquareButtons = useEnableSquareButtons() 833 + const isHiddenElsewhere = hiddenDidsSet.has(account.did) 602 834 603 835 const onSwitchAccount = () => { 604 - if (pendingDid) return 836 + if (pendingDid || disableSwitching) return 605 837 onPressSwitchAccount(account, 'Settings') 606 838 } 607 839 840 + const onToggleHideElsewhere = () => { 841 + setHiddenAccountsElsewhere( 842 + isHiddenElsewhere 843 + ? hiddenAccountsElsewhere.filter(did => did !== account.did) 844 + : [...hiddenAccountsElsewhere, account.did], 845 + ) 846 + Toast.show( 847 + isHiddenElsewhere 848 + ? l`Account will show in other switchers again` 849 + : l`Account hidden from other switchers`, 850 + ) 851 + } 852 + 853 + const onCopyDid = () => { 854 + void setStringAsync(account.did) 855 + Toast.show(l`DID copied to clipboard`) 856 + } 857 + 608 858 return ( 609 - <View style={[a.relative]}> 859 + <View style={[a.relative, {backgroundColor: t.palette.white}]}> 610 860 <SettingsList.PressableItem 611 861 onPress={onSwitchAccount} 612 - label={l`Switch account`}> 862 + label={l`Switch account`} 863 + disabled={Boolean(disableSwitching)} 864 + contentContainerStyle={[ 865 + { 866 + minHeight: 48, 867 + }, 868 + ]}> 613 869 {moderationOpts && profile ? ( 614 870 <UserAvatar 615 871 size={28} ··· 624 880 )} 625 881 <SettingsList.ItemText 626 882 numberOfLines={1} 627 - style={[a.pr_2xl, a.leading_snug]}> 883 + style={[ 884 + a.leading_snug, 885 + a.self_center, 886 + !disableSwitching && a.pr_2xl, 887 + ]}> 628 888 {sanitizeHandle(account.handle, '@')} 629 889 </SettingsList.ItemText> 630 890 {pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />} 891 + {disableSwitching ? ( 892 + <View style={[a.self_center]}>{dragHandle}</View> 893 + ) : null} 631 894 </SettingsList.PressableItem> 632 - {!pendingDid && ( 895 + {!pendingDid && !disableSwitching && ( 633 896 <Menu.Root> 634 897 <Menu.Trigger label={l`Account options`}> 635 898 {({props, state}) => ( ··· 637 900 {...props} 638 901 style={[ 639 902 a.absolute, 640 - {top: 10, right: tokens.space.lg}, 903 + {top: 12, right: tokens.space.lg}, 641 904 a.p_xs, 642 905 enableSquareButtons ? a.rounded_sm : a.rounded_full, 643 906 (state.hovered || state.pressed) && t.atoms.bg_contrast_25, ··· 647 910 )} 648 911 </Menu.Trigger> 649 912 <Menu.Outer showCancel> 913 + <Menu.Item 914 + label={ 915 + isHiddenElsewhere ? l`Hidden elsewhere` : l`Hide elsewhere` 916 + } 917 + onPress={onToggleHideElsewhere}> 918 + <Menu.ItemText> 919 + {isHiddenElsewhere ? ( 920 + <Trans>Hidden elsewhere</Trans> 921 + ) : ( 922 + <Trans>Hide elsewhere</Trans> 923 + )} 924 + </Menu.ItemText> 925 + <Menu.ItemRadio selected={isHiddenElsewhere} /> 926 + </Menu.Item> 927 + {devModeEnabled ? ( 928 + <Menu.Item label={l`Copy DID`} onPress={onCopyDid}> 929 + <Menu.ItemText> 930 + <Trans>Copy DID</Trans> 931 + </Menu.ItemText> 932 + <Menu.ItemIcon icon={ClipboardIcon} /> 933 + </Menu.Item> 934 + ) : null} 935 + <Menu.Divider /> 650 936 <Menu.Item 651 937 label={l`Remove account`} 652 938 onPress={() => removePromptControl.open()}>
+2
src/state/persisted/schema.ts
··· 16 16 service: z.string(), 17 17 did: z.string(), 18 18 handle: z.string(), 19 + addedAt: z.string().optional(), 20 + lastActiveAt: z.string().optional(), 19 21 email: z.string().optional(), 20 22 emailConfirmed: z.boolean().optional(), 21 23 emailAuthFactor: z.boolean().optional(),
+14 -2
src/state/session/index.tsx
··· 64 64 logoutEveryAccount: () => {}, 65 65 resumeSession: async () => {}, 66 66 removeAccount: () => {}, 67 + reorderAccounts: () => {}, 67 68 partialRefreshSession: async () => {}, 68 69 createEphemeralAgent: async () => { 69 70 throw new Error('Not implemented') ··· 324 325 async storedAccount => { 325 326 if (storedAccount.isOauthSession) { 326 327 const {agent} = await oauthResumeSession(storedAccount) 327 - return agent 328 + return agent as unknown as import('@atproto/api').BskyAgent 328 329 } 329 330 const {agent} = await createAgentAndResume( 330 331 storedAccount, ··· 340 341 }) 341 342 }, 342 343 ) 343 - return agent 344 + return agent as import('@atproto/api').BskyAgent 344 345 }, 345 346 [store], 346 347 ) ··· 362 363 }, 363 364 [store, cancelPendingTask], 364 365 ) 366 + const reorderAccounts = useCallback<SessionApiContext['reorderAccounts']>( 367 + accounts => { 368 + store.dispatch({ 369 + type: 'reordered-accounts', 370 + accounts, 371 + }) 372 + }, 373 + [store], 374 + ) 365 375 useEffect(() => { 366 376 return persisted.onUpdate('session', nextSession => { 367 377 const synced = nextSession ··· 418 428 logoutEveryAccount, 419 429 resumeSession, 420 430 removeAccount, 431 + reorderAccounts, 421 432 partialRefreshSession, 422 433 createEphemeralAgent, 423 434 }), ··· 428 439 logoutEveryAccount, 429 440 resumeSession, 430 441 removeAccount, 442 + reorderAccounts, 431 443 partialRefreshSession, 432 444 createEphemeralAgent, 433 445 ],
+30 -6
src/state/session/reducer.ts
··· 45 45 accountDid: string 46 46 } 47 47 | { 48 + type: 'reordered-accounts' 49 + accounts: SessionAccount[] 50 + } 51 + | { 48 52 type: 'logged-out-current-account' 49 53 } 50 54 | { ··· 105 109 accounts: state.accounts.map(a => { 106 110 if (a.did === accountDid) { 107 111 if (refreshedAccount) { 108 - return refreshedAccount 112 + return { 113 + ...refreshedAccount, 114 + addedAt: a.addedAt, 115 + lastActiveAt: a.lastActiveAt, 116 + } 109 117 } else { 110 118 return { 111 119 ...a, ··· 126 134 } 127 135 case 'switched-to-account': { 128 136 const {newAccount, newAgent} = action 137 + const existingAccount = state.accounts.find(a => a.did === newAccount.did) 138 + const now = new Date().toISOString() 139 + const mergedAccount = { 140 + ...existingAccount, 141 + ...newAccount, 142 + addedAt: existingAccount?.addedAt ?? now, 143 + lastActiveAt: now, 144 + } 129 145 return { 130 - accounts: [ 131 - newAccount, 132 - ...state.accounts.filter(a => a.did !== newAccount.did), 133 - ], 146 + accounts: existingAccount 147 + ? state.accounts.map(a => 148 + a.did === mergedAccount.did ? mergedAccount : a, 149 + ) 150 + : [mergedAccount, ...state.accounts], 134 151 currentAgentState: { 135 - did: newAccount.did, 152 + did: mergedAccount.did, 136 153 agent: newAgent, 137 154 }, 138 155 needsPersist: true, ··· 163 180 state.currentAgentState.did === accountDid 164 181 ? createPublicAgentState() // Log out if removing the current one. 165 182 : state.currentAgentState, 183 + needsPersist: true, 184 + } 185 + } 186 + case 'reordered-accounts': { 187 + return { 188 + ...state, 189 + accounts: action.accounts, 166 190 needsPersist: true, 167 191 } 168 192 }
+80
src/state/session/sorting.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {device, useStorage} from '#/storage' 4 + import {type SessionAccount} from './types' 5 + 6 + export type AccountSortOption = 7 + | 'alphabetical' 8 + | 'dateModified' 9 + | 'dateAdded' 10 + | 'custom' 11 + 12 + type SortableAccountItem = { 13 + account: Pick<SessionAccount, 'handle' | 'addedAt' | 'lastActiveAt'> 14 + } 15 + 16 + function getSortableAccount( 17 + item: SessionAccount | SortableAccountItem, 18 + ): Pick<SessionAccount, 'handle' | 'addedAt' | 'lastActiveAt'> { 19 + return 'account' in item ? item.account : item 20 + } 21 + 22 + export function sortAccountItems< 23 + T extends SessionAccount | SortableAccountItem, 24 + >(accounts: T[], sortBy: AccountSortOption, reverse: boolean) { 25 + const next = [...accounts] 26 + if (sortBy === 'alphabetical') { 27 + next.sort((a, b) => 28 + getSortableAccount(a).handle.localeCompare( 29 + getSortableAccount(b).handle, 30 + undefined, 31 + { 32 + sensitivity: 'base', 33 + }, 34 + ), 35 + ) 36 + } else if (sortBy === 'dateModified') { 37 + next.sort((a, b) => { 38 + const left = Date.parse(getSortableAccount(a).lastActiveAt ?? '') || 0 39 + const right = Date.parse(getSortableAccount(b).lastActiveAt ?? '') || 0 40 + return right - left 41 + }) 42 + } else if (sortBy === 'dateAdded') { 43 + next.sort((a, b) => { 44 + const left = Date.parse(getSortableAccount(a).addedAt ?? '') || 0 45 + const right = Date.parse(getSortableAccount(b).addedAt ?? '') || 0 46 + return right - left 47 + }) 48 + } 49 + if (reverse) { 50 + next.reverse() 51 + } 52 + return next 53 + } 54 + 55 + export function useAccountSwitcherSortSettings() { 56 + const [storedSortBy, setStoredSortBy] = useStorage(device, [ 57 + 'settingsAccountSwitcherSortBy', 58 + ]) 59 + const [reverseAccounts = false, setReverseAccounts] = useStorage(device, [ 60 + 'settingsAccountSwitcherReverse', 61 + ]) 62 + 63 + return { 64 + sortBy: storedSortBy ?? ('dateModified' as AccountSortOption), 65 + setSortBy: setStoredSortBy, 66 + reverse: reverseAccounts, 67 + setReverse: setReverseAccounts, 68 + } 69 + } 70 + 71 + export function useSortedAccountItems< 72 + T extends SessionAccount | SortableAccountItem, 73 + >(accounts: T[]) { 74 + const {sortBy, reverse} = useAccountSwitcherSortSettings() 75 + 76 + return useMemo( 77 + () => sortAccountItems(accounts, sortBy, reverse), 78 + [accounts, sortBy, reverse], 79 + ) 80 + }
+1
src/state/session/types.ts
··· 46 46 isSwitchingAccounts?: boolean, 47 47 ) => Promise<void> 48 48 removeAccount: (account: SessionAccount) => void 49 + reorderAccounts: (accounts: SessionAccount[]) => void 49 50 /** 50 51 * Calls `getSession` and updates select fields on the current account and 51 52 * `BskyAgent`. This is an alternative to `resumeSession`, which updates
+21
src/storage/hooks/hidden-accounts-elsewhere.ts
··· 1 + import {useMemo} from 'react' 2 + 3 + import {device, useStorage} from '#/storage' 4 + 5 + export function useHiddenAccountsElsewhere() { 6 + const [hiddenAccountsElsewhere = [], setHiddenAccountsElsewhere] = useStorage( 7 + device, 8 + ['hiddenAccountsElsewhere'], 9 + ) 10 + 11 + const hiddenDidsSet = useMemo( 12 + () => new Set(hiddenAccountsElsewhere), 13 + [hiddenAccountsElsewhere], 14 + ) 15 + 16 + return [ 17 + hiddenAccountsElsewhere, 18 + setHiddenAccountsElsewhere, 19 + hiddenDidsSet, 20 + ] as const 21 + }
+7
src/storage/schema.ts
··· 64 64 activitySubscriptionsNudged?: boolean 65 65 threadgateNudged?: boolean 66 66 customAppViewDid: string | undefined 67 + hiddenAccountsElsewhere?: string[] 68 + settingsAccountSwitcherSortBy?: 69 + | 'alphabetical' 70 + | 'dateModified' 71 + | 'dateAdded' 72 + | 'custom' 73 + settingsAccountSwitcherReverse?: boolean 67 74 68 75 /** 69 76 * Policy update overlays. New IDs are required for each new announcement.
+26 -16
src/view/com/composer/Composer.tsx
··· 112 112 import {type Gif} from '#/state/queries/tenor' 113 113 import {useAgent, useSession, useSessionApi} from '#/state/session' 114 114 import {useComposerControls} from '#/state/shell/composer' 115 - import {useLoggedOutViewControls} from '#/state/shell/logged-out' 116 115 import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' 116 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 117 117 import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 118 118 import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' 119 119 import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton' ··· 212 212 }: Props & { 213 213 cancelRef?: RefObject<CancelRef | null> 214 214 }) => { 215 - const {currentAccount, accounts} = useSession() 215 + const {accounts} = useSession() 216 216 const t = useTheme() 217 217 const ax = useAnalytics() 218 218 const agent = useAgent() ··· 920 920 const activeAccount = accounts.find(a => a.did === activeAccountDid) 921 921 if (activeAccount) { 922 922 try { 923 - ephemeralAgent = await sessionApi.createEphemeralAgent(activeAccount) 923 + ephemeralAgent = 924 + await sessionApi.createEphemeralAgent(activeAccount) 924 925 currentAgent = ephemeralAgent 925 926 } catch (e) { 926 - logger.error('Composer: failed to create ephemeral agent for account switch', { 927 - message: e instanceof Error ? e.message : String(e), 928 - }) 927 + logger.error( 928 + 'Composer: failed to create ephemeral agent for account switch', 929 + { 930 + message: e instanceof Error ? e.message : String(e), 931 + }, 932 + ) 929 933 setIsPublishing(false) 930 934 requestSwitchToAccount({requestedAccount: activeAccount.did}) 931 - Toast.show(l`Please sign in as @${activeAccount.handle} to post as them`, { 932 - type: 'warning', 933 - }) 935 + Toast.show( 936 + l`Please sign in as @${activeAccount.handle} to post as them`, 937 + { 938 + type: 'warning', 939 + }, 940 + ) 934 941 return 935 942 } 936 943 } ··· 971 978 5, 972 979 _e => true, 973 980 async () => { 974 - const res = await currentAgent.app.bsky.unspecced.getPostThreadV2({ 975 - anchor: postUri!, 976 - above: false, 977 - below: filteredThread.posts.length - 1, 978 - branchingFactor: 1, 979 - }) 981 + const res = await currentAgent.app.bsky.unspecced.getPostThreadV2( 982 + { 983 + anchor: postUri!, 984 + above: false, 985 + below: filteredThread.posts.length - 1, 986 + branchingFactor: 1, 987 + }, 988 + ) 980 989 if (res.data.thread.length !== filteredThread.posts.length) { 981 990 throw new Error(`composer: app view is not ready`) 982 991 } ··· 1467 1476 activeAccountDid: string 1468 1477 setActiveAccountDid: (did: string) => void 1469 1478 }) { 1470 - const {currentAccount, accounts} = useSession() 1479 + const {accounts} = useSession() 1471 1480 const {t: l} = useLingui() 1472 1481 const {data: currentProfile} = useProfileQuery({did: activeAccountDid}) 1473 1482 const richtext = post.richtext ··· 1544 1553 const profiles = data?.profiles 1545 1554 1546 1555 const allAccounts = accounts 1556 + .filter(account => account.did !== activeAccountDid) 1547 1557 .map(account => ({ 1548 1558 account, 1549 1559 profile: profiles?.find(p => p.did === account.did),
+8 -2
src/view/shell/desktop/LeftNav.tsx
··· 25 25 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 26 26 import {useProfilesQuery} from '#/state/queries/profile' 27 27 import {type SessionAccount, useSession, useSessionApi} from '#/state/session' 28 + import {useSortedAccountItems} from '#/state/session/sorting' 28 29 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 29 30 import {useCloseAllActiveElements} from '#/state/util' 30 31 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' ··· 77 78 import {Text} from '#/components/Typography' 78 79 import {useAgeAssurance} from '#/ageAssurance' 79 80 import {useActorStatus} from '#/features/liveNow' 81 + import {useHiddenAccountsElsewhere} from '#/storage/hooks/hidden-accounts-elsewhere' 80 82 import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army' 81 83 import {router} from '../../../routes' 82 84 ··· 241 243 const {_} = useLingui() 242 244 const {setShowLoggedOut} = useLoggedOutViewControls() 243 245 const closeEverything = useCloseAllActiveElements() 246 + const [, , hiddenDidsSet] = useHiddenAccountsElsewhere() 247 + const sortedAccounts = useSortedAccountItems(accounts ?? []).filter( 248 + item => !hiddenDidsSet.has(item.account.did), 249 + ) 244 250 245 251 showExtraButtons = showExtraButtons ?? true 246 252 ··· 251 257 252 258 return ( 253 259 <Menu.Outer> 254 - {accounts && accounts.length > 0 && ( 260 + {sortedAccounts.length > 0 && ( 255 261 <> 256 262 <Menu.Group> 257 263 <Menu.LabelText> 258 264 <Trans>Switch account</Trans> 259 265 </Menu.LabelText> 260 - {accounts.map(other => ( 266 + {sortedAccounts.map(other => ( 261 267 <SwitchMenuItem 262 268 key={other.account.did} 263 269 account={other.account}