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