Bluesky app fork with some witchin' additions 馃挮
at main 440 lines 16 kB view raw
1import {type JSX, useCallback} from 'react' 2import {type GestureResponderEvent, View} from 'react-native' 3import Animated from 'react-native-reanimated' 4import {useSafeAreaInsets} from 'react-native-safe-area-context' 5import {msg, plural, Trans} from '@lingui/macro' 6import {useLingui} from '@lingui/react' 7import {type BottomTabBarProps} from '@react-navigation/bottom-tabs' 8import {StackActions} from '@react-navigation/native' 9 10import {useActorStatus} from '#/lib/actor-status' 11import {PressableScale} from '#/lib/custom-animations/PressableScale' 12import {BOTTOM_BAR_AVI} from '#/lib/demo' 13import {useHaptics} from '#/lib/haptics' 14import {useDedupe} from '#/lib/hooks/useDedupe' 15import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' 16import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' 17import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' 18import {usePalette} from '#/lib/hooks/usePalette' 19import {clamp} from '#/lib/numbers' 20import {getTabState, TabState} from '#/lib/routes/helpers' 21import {useGate} from '#/lib/statsig/statsig' 22import {emitSoftReset} from '#/state/events' 23import {useHomeBadge} from '#/state/home-badge' 24import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 25import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 26import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' 27import {useUnreadNotifications} from '#/state/queries/notifications/unread' 28import {useProfileQuery} from '#/state/queries/profile' 29import {useSession} from '#/state/session' 30import {useLoggedOutViewControls} from '#/state/shell/logged-out' 31import {useShellLayout} from '#/state/shell/shell-layout' 32import {useCloseAllActiveElements} from '#/state/util' 33import {UserAvatar} from '#/view/com/util/UserAvatar' 34import {Logo} from '#/view/icons/Logo' 35import {Logotype} from '#/view/icons/Logotype' 36import {atoms as a, useTheme} from '#/alf' 37import {Button, ButtonText} from '#/components/Button' 38import {useDialogControl} from '#/components/Dialog' 39import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 40import { 41 Bell_Filled_Corner0_Rounded as BellFilled, 42 Bell_Stroke2_Corner0_Rounded as Bell, 43} from '#/components/icons/Bell' 44import { 45 HomeOpen_Filled_Corner0_Rounded as HomeFilled, 46 HomeOpen_Stoke2_Corner0_Rounded as Home, 47} from '#/components/icons/HomeOpen' 48import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 49import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 50import { 51 Message_Stroke2_Corner0_Rounded as Message, 52 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 53} from '#/components/icons/Message' 54import {Text} from '#/components/Typography' 55import {useDemoMode} from '#/storage/hooks/demo-mode' 56import {styles} from './BottomBarStyles' 57 58type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile' 59 60export function BottomBar({navigation}: BottomTabBarProps) { 61 const {hasSession, currentAccount} = useSession() 62 const pal = usePalette('default') 63 const {_} = useLingui() 64 const safeAreaInsets = useSafeAreaInsets() 65 const {footerHeight} = useShellLayout() 66 const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile, isAtMessages} = 67 useNavigationTabState() 68 const numUnreadNotifications = useUnreadNotifications() 69 const numUnreadMessages = useUnreadMessageCount() 70 const footerMinimalShellTransform = useMinimalShellFooterTransform() 71 const {data: profile} = useProfileQuery({did: currentAccount?.did}) 72 const {requestSwitchToAccount} = useLoggedOutViewControls() 73 const closeAllActiveElements = useCloseAllActiveElements() 74 const dedupe = useDedupe() 75 const accountSwitchControl = useDialogControl() 76 const playHaptic = useHaptics() 77 const hasHomeBadge = useHomeBadge() 78 const gate = useGate() 79 const hideBorder = useHideBottomBarBorder() 80 const iconWidth = 28 81 82 const showSignIn = useCallback(() => { 83 closeAllActiveElements() 84 requestSwitchToAccount({requestedAccount: 'none'}) 85 }, [requestSwitchToAccount, closeAllActiveElements]) 86 87 const showCreateAccount = useCallback(() => { 88 closeAllActiveElements() 89 requestSwitchToAccount({requestedAccount: 'new'}) 90 // setShowLoggedOut(true) 91 }, [requestSwitchToAccount, closeAllActiveElements]) 92 93 const onPressTab = useCallback( 94 (tab: TabOptions) => { 95 const state = navigation.getState() 96 const tabState = getTabState(state, tab) 97 if (tabState === TabState.InsideAtRoot) { 98 emitSoftReset() 99 } else if (tabState === TabState.Inside) { 100 // find the correct navigator in which to pop-to-top 101 const target = state.routes.find(route => route.name === `${tab}Tab`) 102 ?.state?.key 103 dedupe(() => { 104 if (target) { 105 // if we found it, trigger pop-to-top 106 navigation.dispatch({ 107 ...StackActions.popToTop(), 108 target, 109 }) 110 } else { 111 // fallback: reset navigation 112 navigation.reset({ 113 index: 0, 114 routes: [{name: `${tab}Tab`}], 115 }) 116 } 117 }) 118 } else { 119 dedupe(() => navigation.navigate(`${tab}Tab`)) 120 } 121 }, 122 [navigation, dedupe], 123 ) 124 const onPressHome = useCallback(() => onPressTab('Home'), [onPressTab]) 125 const onPressSearch = useCallback(() => onPressTab('Search'), [onPressTab]) 126 const onPressNotifications = useCallback( 127 () => onPressTab('Notifications'), 128 [onPressTab], 129 ) 130 const onPressProfile = useCallback(() => { 131 onPressTab('MyProfile') 132 }, [onPressTab]) 133 const onPressMessages = useCallback(() => { 134 onPressTab('Messages') 135 }, [onPressTab]) 136 137 const onLongPressProfile = useCallback(() => { 138 playHaptic() 139 accountSwitchControl.open() 140 }, [accountSwitchControl, playHaptic]) 141 142 const [demoMode] = useDemoMode() 143 const {isActive: live} = useActorStatus(profile) 144 145 const enableSquareAvatars = useEnableSquareAvatars() 146 147 return ( 148 <> 149 <SwitchAccountDialog control={accountSwitchControl} /> 150 151 <Animated.View 152 style={[ 153 styles.bottomBar, 154 pal.view, 155 hideBorder ? {borderColor: pal.view.backgroundColor} : pal.border, 156 {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)}, 157 footerMinimalShellTransform, 158 ]} 159 onLayout={e => { 160 footerHeight.set(e.nativeEvent.layout.height) 161 }}> 162 {hasSession ? ( 163 <> 164 <Btn 165 testID="bottomBarHomeBtn" 166 icon={ 167 isAtHome ? ( 168 <HomeFilled 169 width={iconWidth + 1} 170 style={[styles.ctrlIcon, pal.text, styles.homeIcon]} 171 /> 172 ) : ( 173 <Home 174 width={iconWidth + 1} 175 style={[styles.ctrlIcon, pal.text, styles.homeIcon]} 176 /> 177 ) 178 } 179 hasNew={hasHomeBadge && gate('remove_show_latest_button')} 180 onPress={onPressHome} 181 accessibilityRole="tab" 182 accessibilityLabel={_(msg`Home`)} 183 accessibilityHint="" 184 /> 185 <Btn 186 icon={ 187 isAtSearch ? ( 188 <MagnifyingGlassFilled 189 width={iconWidth + 2} 190 style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 191 /> 192 ) : ( 193 <MagnifyingGlass 194 testID="bottomBarSearchBtn" 195 width={iconWidth + 2} 196 style={[styles.ctrlIcon, pal.text, styles.searchIcon]} 197 /> 198 ) 199 } 200 onPress={onPressSearch} 201 accessibilityRole="search" 202 accessibilityLabel={_(msg`Search`)} 203 accessibilityHint="" 204 /> 205 <Btn 206 testID="bottomBarMessagesBtn" 207 icon={ 208 isAtMessages ? ( 209 <MessageFilled 210 width={iconWidth - 1} 211 style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 212 /> 213 ) : ( 214 <Message 215 width={iconWidth - 1} 216 style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 217 /> 218 ) 219 } 220 onPress={onPressMessages} 221 notificationCount={numUnreadMessages.numUnread} 222 hasNew={numUnreadMessages.hasNew} 223 accessible={true} 224 accessibilityRole="tab" 225 accessibilityLabel={_(msg`Chat`)} 226 accessibilityHint={ 227 numUnreadMessages.count > 0 228 ? _( 229 msg`${plural(numUnreadMessages.numUnread ?? 0, { 230 one: '# unread item', 231 other: '# unread items', 232 })}` || '', 233 ) 234 : '' 235 } 236 /> 237 <Btn 238 testID="bottomBarNotificationsBtn" 239 icon={ 240 isAtNotifications ? ( 241 <BellFilled 242 width={iconWidth} 243 style={[styles.ctrlIcon, pal.text, styles.bellIcon]} 244 /> 245 ) : ( 246 <Bell 247 width={iconWidth} 248 style={[styles.ctrlIcon, pal.text, styles.bellIcon]} 249 /> 250 ) 251 } 252 onPress={onPressNotifications} 253 notificationCount={numUnreadNotifications} 254 accessible={true} 255 accessibilityRole="tab" 256 accessibilityLabel={_(msg`Notifications`)} 257 accessibilityHint={ 258 numUnreadNotifications === '' 259 ? '' 260 : _( 261 msg`${plural(numUnreadNotifications ?? 0, { 262 one: '# unread item', 263 other: '# unread items', 264 })}` || '', 265 ) 266 } 267 /> 268 <Btn 269 testID="bottomBarProfileBtn" 270 icon={ 271 <View style={styles.ctrlIconSizingWrapper}> 272 {isAtMyProfile ? ( 273 <View 274 style={[ 275 styles.ctrlIcon, 276 pal.text, 277 styles.profileIcon, 278 enableSquareAvatars 279 ? styles.onProfileSquare 280 : styles.onProfile, 281 { 282 borderColor: pal.text.color, 283 borderWidth: live ? 0 : enableSquareAvatars ? 1.5 : 1, 284 }, 285 ]}> 286 <UserAvatar 287 avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} 288 size={iconWidth - 2} 289 // See https://github.com/bluesky-social/social-app/pull/1801: 290 usePlainRNImage={true} 291 type={profile?.associated?.labeler ? 'labeler' : 'user'} 292 live={live} 293 hideLiveBadge 294 /> 295 </View> 296 ) : ( 297 <View 298 style={[ 299 styles.ctrlIcon, 300 pal.text, 301 styles.profileIcon, 302 { 303 borderWidth: live ? 0 : 1, 304 }, 305 ]}> 306 <UserAvatar 307 avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar} 308 size={iconWidth - 2} 309 // See https://github.com/bluesky-social/social-app/pull/1801: 310 usePlainRNImage={true} 311 type={profile?.associated?.labeler ? 'labeler' : 'user'} 312 live={live} 313 hideLiveBadge 314 /> 315 </View> 316 )} 317 </View> 318 } 319 onPress={onPressProfile} 320 onLongPress={onLongPressProfile} 321 accessibilityRole="tab" 322 accessibilityLabel={_(msg`Profile`)} 323 accessibilityHint="" 324 /> 325 </> 326 ) : ( 327 <> 328 <View 329 style={{ 330 width: '100%', 331 flexDirection: 'row', 332 alignItems: 'center', 333 justifyContent: 'space-between', 334 paddingTop: 14, 335 paddingBottom: 2, 336 paddingLeft: 14, 337 paddingRight: 6, 338 gap: 8, 339 }}> 340 <View 341 style={{flexDirection: 'row', alignItems: 'center', gap: 8}}> 342 <Logo width={28} /> 343 <View style={{paddingTop: 4}}> 344 <Logotype width={80} fill={pal.text.color} /> 345 </View> 346 </View> 347 348 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}> 349 <Button 350 onPress={showCreateAccount} 351 label={_(msg`Create account`)} 352 size="small" 353 variant="solid" 354 color="primary"> 355 <ButtonText> 356 <Trans>Create account</Trans> 357 </ButtonText> 358 </Button> 359 <Button 360 onPress={showSignIn} 361 label={_(msg`Sign in`)} 362 size="small" 363 variant="solid" 364 color="secondary"> 365 <ButtonText> 366 <Trans>Sign in</Trans> 367 </ButtonText> 368 </Button> 369 </View> 370 </View> 371 </> 372 )} 373 </Animated.View> 374 </> 375 ) 376} 377 378interface BtnProps 379 extends Pick< 380 React.ComponentProps<typeof PressableScale>, 381 | 'accessible' 382 | 'accessibilityRole' 383 | 'accessibilityHint' 384 | 'accessibilityLabel' 385 > { 386 testID?: string 387 icon: JSX.Element 388 notificationCount?: string 389 hasNew?: boolean 390 onPress?: (event: GestureResponderEvent) => void 391 onLongPress?: (event: GestureResponderEvent) => void 392} 393 394function Btn({ 395 testID, 396 icon, 397 hasNew, 398 notificationCount, 399 onPress, 400 onLongPress, 401 accessible, 402 accessibilityHint, 403 accessibilityLabel, 404}: BtnProps) { 405 const enableSquareButtons = useEnableSquareButtons() 406 const t = useTheme() 407 408 return ( 409 <PressableScale 410 testID={testID} 411 style={[styles.ctrl, a.flex_1]} 412 onPress={onPress} 413 onLongPress={onLongPress} 414 accessible={accessible} 415 accessibilityLabel={accessibilityLabel} 416 accessibilityHint={accessibilityHint} 417 targetScale={0.8} 418 accessibilityLargeContentTitle={accessibilityLabel} 419 accessibilityShowsLargeContentViewer> 420 {icon} 421 {notificationCount ? ( 422 <View 423 style={[ 424 styles.notificationCount, 425 enableSquareButtons ? a.rounded_sm : a.rounded_full, 426 {backgroundColor: t.palette.primary_500}, 427 ]}> 428 <Text style={styles.notificationCountLabel}>{notificationCount}</Text> 429 </View> 430 ) : hasNew ? ( 431 <View 432 style={[ 433 styles.hasNewBadge, 434 enableSquareButtons ? a.rounded_sm : a.rounded_full, 435 ]} 436 /> 437 ) : null} 438 </PressableScale> 439 ) 440}