Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 470 lines 16 kB view raw
1import {useCallback, useRef, useState} from 'react' 2import {Pressable, View} from 'react-native' 3import Animated from 'react-native-reanimated' 4import {useSafeAreaInsets} from 'react-native-safe-area-context' 5import {sanitizeUrl} from '@braintree/sanitize-url' 6import {msg, plural} from '@lingui/core/macro' 7import {useLingui} from '@lingui/react' 8import {Trans} from '@lingui/react/macro' 9import {StackActions, useNavigationState} from '@react-navigation/native' 10 11import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' 12import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' 13import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' 14import { 15 getCurrentRoute, 16 getTabState, 17 isTab, 18 TabState, 19} from '#/lib/routes/helpers' 20import {makeProfileLink} from '#/lib/routes/links' 21import {type CommonNavigatorParams} from '#/lib/routes/types' 22import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers' 23import {emitSoftReset} from '#/state/events' 24import {useModalControls} from '#/state/modals' 25import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' 26import {useUnreadNotifications} from '#/state/queries/notifications/unread' 27import {useProfileQuery} from '#/state/queries/profile' 28import {useSession} from '#/state/session' 29import {useLoggedOutViewControls} from '#/state/shell/logged-out' 30import {useShellLayout} from '#/state/shell/shell-layout' 31import {useCloseAllActiveElements} from '#/state/util' 32import {Link} from '#/view/com/util/Link' 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 { 49 MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled, 50 MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass, 51} from '#/components/icons/MagnifyingGlass' 52import { 53 Message_Stroke2_Corner0_Rounded as Message, 54 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 55} from '#/components/icons/Message' 56import {Text} from '#/components/Typography' 57import {useAgeAssurance} from '#/ageAssurance' 58import {IS_WEB_TOUCH_DEVICE} from '#/env' 59import {router} from '#/routes' 60import {styles} from './BottomBarStyles' 61 62export function BottomBarWeb() { 63 const {_} = useLingui() 64 const {hasSession, currentAccount} = useSession() 65 const t = useTheme() 66 const {bottom: bottomInset} = useSafeAreaInsets() 67 const footerMinimalShellTransform = useMinimalShellFooterTransform() 68 const {requestSwitchToAccount} = useLoggedOutViewControls() 69 const closeAllActiveElements = useCloseAllActiveElements() 70 const {footerHeight} = useShellLayout() 71 const hideBorder = useHideBottomBarBorder() 72 const accountSwitchControl = useDialogControl() 73 const {data: profile} = useProfileQuery({did: currentAccount?.did}) 74 const iconWidth = 26 75 76 const unreadMessageCount = useUnreadMessageCount() 77 const notificationCountStr = useUnreadNotifications() 78 const aa = useAgeAssurance() 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 onLongPressProfile = useCallback(() => { 92 accountSwitchControl.open() 93 }, [accountSwitchControl]) 94 95 return ( 96 <> 97 <SwitchAccountDialog control={accountSwitchControl} /> 98 99 <Animated.View 100 role="navigation" 101 style={[ 102 styles.bottomBar, 103 styles.bottomBarWeb, 104 t.atoms.bg, 105 IS_WEB_TOUCH_DEVICE 106 ? {paddingBottom: Math.max(bottomInset, 15)} 107 : {paddingBottom: bottomInset}, 108 hideBorder 109 ? {borderColor: t.atoms.bg.backgroundColor} 110 : t.atoms.border_contrast_low, 111 footerMinimalShellTransform, 112 ]} 113 onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}> 114 {hasSession ? ( 115 <> 116 <NavItem routeName="Home" href="/"> 117 {({isActive}) => { 118 const Icon = isActive ? HomeFilled : Home 119 return ( 120 <Icon 121 aria-hidden={true} 122 width={iconWidth + 1} 123 style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]} 124 /> 125 ) 126 }} 127 </NavItem> 128 <NavItem routeName="Search" href="/search"> 129 {({isActive}) => { 130 const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass 131 return ( 132 <Icon 133 aria-hidden={true} 134 width={iconWidth + 2} 135 style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]} 136 /> 137 ) 138 }} 139 </NavItem> 140 141 {hasSession && ( 142 <> 143 <NavItem 144 routeName="Messages" 145 href="/messages" 146 notificationCount={ 147 aa.flags.chatDisabled 148 ? undefined 149 : unreadMessageCount.numUnread 150 } 151 hasNew={ 152 aa.flags.chatDisabled ? false : unreadMessageCount.hasNew 153 }> 154 {({isActive}) => { 155 const Icon = isActive ? MessageFilled : Message 156 return ( 157 <Icon 158 aria-hidden={true} 159 width={iconWidth - 1} 160 style={[ 161 styles.ctrlIcon, 162 t.atoms.text, 163 styles.messagesIcon, 164 ]} 165 /> 166 ) 167 }} 168 </NavItem> 169 <NavItem 170 routeName="Notifications" 171 href="/notifications" 172 notificationCount={notificationCountStr}> 173 {({isActive}) => { 174 const Icon = isActive ? BellFilled : Bell 175 return ( 176 <Icon 177 aria-hidden={true} 178 width={iconWidth} 179 style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]} 180 /> 181 ) 182 }} 183 </NavItem> 184 <NavItem 185 routeName="Profile" 186 href={ 187 currentAccount 188 ? makeProfileLink({ 189 did: currentAccount.did, 190 handle: currentAccount.handle, 191 }) 192 : '/' 193 } 194 onLongPress={onLongPressProfile}> 195 {({isActive}) => ( 196 <View style={styles.ctrlIconSizingWrapper}> 197 <View 198 style={[ 199 styles.ctrlIcon, 200 styles.profileIcon, 201 isActive && [ 202 styles.onProfile, 203 {borderColor: t.atoms.text.color}, 204 ], 205 ]}> 206 <UserAvatar 207 avatar={profile?.avatar} 208 size={iconWidth - 3} 209 type={ 210 profile?.associated?.labeler ? 'labeler' : 'user' 211 } 212 /> 213 </View> 214 </View> 215 )} 216 </NavItem> 217 </> 218 )} 219 </> 220 ) : ( 221 <> 222 <View 223 style={[ 224 a.w_full, 225 a.flex_row, 226 a.align_center, 227 a.justify_between, 228 a.gap_sm, 229 { 230 paddingTop: 14, 231 paddingBottom: 14, 232 paddingLeft: 14, 233 paddingRight: 6, 234 }, 235 ]}> 236 <View style={[a.flex_row, a.align_center, a.gap_md]}> 237 <Logo width={32} /> 238 <View style={{paddingTop: 4}}> 239 <Logotype width={80} fill={t.atoms.text.color} /> 240 </View> 241 </View> 242 243 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}> 244 <Button 245 onPress={showCreateAccount} 246 label={_(msg`Create account`)} 247 size="small" 248 variant="solid" 249 color="primary"> 250 <ButtonText> 251 <Trans>Create account</Trans> 252 </ButtonText> 253 </Button> 254 <Button 255 onPress={showSignIn} 256 label={_(msg`Sign in`)} 257 size="small" 258 variant="solid" 259 color="secondary"> 260 <ButtonText> 261 <Trans>Sign in</Trans> 262 </ButtonText> 263 </Button> 264 </View> 265 </View> 266 </> 267 )} 268 </Animated.View> 269 </> 270 ) 271} 272 273const NavItem: React.FC<{ 274 children: (props: {isActive: boolean}) => React.ReactNode 275 href: string 276 routeName: string 277 hasNew?: boolean 278 notificationCount?: string 279 onLongPress?: () => void 280}> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => { 281 const t = useTheme() 282 const {_} = useLingui() 283 const {bottom: bottomInset} = useSafeAreaInsets() 284 const {currentAccount} = useSession() 285 const currentRoute = useNavigationState(state => { 286 if (!state) { 287 return {name: 'Home'} 288 } 289 return getCurrentRoute(state) 290 }) 291 292 // Checks whether we're on someone else's profile 293 const isOnDifferentProfile = 294 currentRoute.name === 'Profile' && 295 routeName === 'Profile' && 296 (currentRoute.params as CommonNavigatorParams['Profile']).name !== 297 currentAccount?.handle 298 299 const isActive = 300 currentRoute.name === 'Profile' 301 ? isTab(currentRoute.name, routeName) && 302 (currentRoute.params as CommonNavigatorParams['Profile']).name === 303 (routeName === 'Profile' 304 ? currentAccount?.handle 305 : (currentRoute.params as CommonNavigatorParams['Profile']).name) 306 : isTab(currentRoute.name, routeName) 307 308 if (IS_WEB_TOUCH_DEVICE) { 309 return ( 310 <TouchNavItem 311 href={href} 312 routeName={routeName} 313 isActive={isActive} 314 isOnDifferentProfile={isOnDifferentProfile} 315 hasNew={hasNew} 316 notificationCount={notificationCount} 317 onLongPress={onLongPress}> 318 {children} 319 </TouchNavItem> 320 ) 321 } 322 323 return ( 324 <Link 325 href={href} 326 style={[styles.ctrl, bottomInset === 0 && a.pb_lg]} 327 navigationAction={isOnDifferentProfile ? 'push' : 'navigate'} 328 aria-role="link" 329 aria-label={routeName} 330 accessible={true} 331 onLongPress={onLongPress}> 332 {children({isActive})} 333 {notificationCount ? ( 334 <View 335 style={[ 336 styles.notificationCount, 337 styles.notificationCountWeb, 338 {backgroundColor: t.palette.primary_500}, 339 ]} 340 aria-label={_( 341 msg`${plural(notificationCount, { 342 one: '# unread item', 343 other: '# unread items', 344 })}`, 345 )}> 346 <Text style={styles.notificationCountLabel}>{notificationCount}</Text> 347 </View> 348 ) : hasNew ? ( 349 <View 350 style={[styles.hasNewBadge, {backgroundColor: t.palette.primary_500}]} 351 /> 352 ) : null} 353 </Link> 354 ) 355} 356 357function TouchNavItem({ 358 children, 359 href, 360 routeName, 361 isActive, 362 isOnDifferentProfile, 363 hasNew, 364 notificationCount, 365 onLongPress, 366}: { 367 children: (props: {isActive: boolean}) => React.ReactNode 368 href: string 369 routeName: string 370 isActive: boolean 371 isOnDifferentProfile: boolean 372 hasNew?: boolean 373 notificationCount?: string 374 onLongPress?: () => void 375}) { 376 const t = useTheme() 377 const {_} = useLingui() 378 const {bottom: bottomInset} = useSafeAreaInsets() 379 const navigation = useNavigationDeduped() 380 const {closeModal} = useModalControls() 381 382 // CSS transition press animation — runs on compositor thread 383 // so navigation re-renders don't cause jank 384 const [pressed, setPressed] = useState(false) 385 const pressInTime = useRef(0) 386 const pressOutTimer = useRef<ReturnType<typeof setTimeout> | undefined>( 387 undefined, 388 ) 389 const ANIM_MS = 100 390 391 const handlePressIn = () => { 392 if (pressOutTimer.current) { 393 clearTimeout(pressOutTimer.current) 394 pressOutTimer.current = undefined 395 } 396 pressInTime.current = Date.now() 397 setPressed(true) 398 } 399 400 const handlePressOut = () => { 401 const elapsed = Date.now() - pressInTime.current 402 const remaining = Math.max(0, ANIM_MS - elapsed) 403 // Wait for scale-down to finish before starting scale-up 404 pressOutTimer.current = setTimeout(() => { 405 setPressed(false) 406 pressOutTimer.current = undefined 407 }, remaining) 408 } 409 410 const onPress = () => { 411 closeModal() 412 413 const sanitizedHref = convertBskyAppUrlIfNeeded(sanitizeUrl(href)) 414 const [resolvedRouteName, params] = router.matchPath(sanitizedHref) 415 416 if (isOnDifferentProfile) { 417 // @ts-ignore we're not able to type check on this one -prf 418 navigation.dispatch(StackActions.push(resolvedRouteName, params)) 419 } else { 420 const state = navigation.getState() 421 const tabState = getTabState(state, resolvedRouteName) 422 if (tabState === TabState.InsideAtRoot) { 423 emitSoftReset() 424 } else { 425 // @ts-ignore we're not able to type check on this one -prf 426 navigation.navigate(resolvedRouteName, params, {pop: true}) 427 } 428 } 429 } 430 431 return ( 432 <Pressable 433 style={[ 434 styles.ctrl, 435 bottomInset === 0 && a.pb_lg, 436 { 437 transition: `transform ${ANIM_MS}ms`, 438 transform: [{scale: pressed ? 0.8 : 1}], 439 } as any, 440 ]} 441 onPress={onPress} 442 onLongPress={onLongPress} 443 onPressIn={handlePressIn} 444 onPressOut={handlePressOut} 445 unstable_pressDelay={0} 446 accessibilityRole="link" 447 accessibilityLabel={routeName} 448 accessibilityHint=""> 449 {children({isActive})} 450 {notificationCount ? ( 451 <View 452 style={[ 453 styles.notificationCount, 454 styles.notificationCountWeb, 455 {backgroundColor: t.palette.primary_500}, 456 ]} 457 aria-label={_( 458 msg`${plural(notificationCount, { 459 one: '# unread item', 460 other: '# unread items', 461 })}`, 462 )}> 463 <Text style={styles.notificationCountLabel}>{notificationCount}</Text> 464 </View> 465 ) : hasNew ? ( 466 <View style={styles.hasNewBadge} /> 467 ) : null} 468 </Pressable> 469 ) 470}