Bluesky app fork with some witchin' additions 馃挮
at 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 725 lines 20 kB view raw
1import React, {type ComponentProps, type JSX} from 'react' 2import {Linking, ScrollView, TouchableOpacity, View} from 'react-native' 3import {useSafeAreaInsets} from 'react-native-safe-area-context' 4import {msg, plural} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Plural, Trans} from '@lingui/react/macro' 7import {StackActions, useNavigation} from '@react-navigation/native' 8 9import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 10import {type PressableScale} from '#/lib/custom-animations/PressableScale' 11import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState' 12import {getTabState, TabState} from '#/lib/routes/helpers' 13import {type NavigationProp} from '#/lib/routes/types' 14import {sanitizeHandle} from '#/lib/strings/handles' 15import {colors} from '#/lib/styles' 16import {emitSoftReset} from '#/state/events' 17import {useKawaiiMode} from '#/state/preferences/kawaii' 18import {useUnreadNotifications} from '#/state/queries/notifications/unread' 19import {useProfileQuery} from '#/state/queries/profile' 20import {type SessionAccount, useSession} from '#/state/session' 21import {useSetDrawerOpen} from '#/state/shell' 22import {formatCount} from '#/view/com/util/numeric/format' 23import {UserAvatar} from '#/view/com/util/UserAvatar' 24import {NavSignupCard} from '#/view/shell/NavSignupCard' 25import {atoms as a, tokens, useTheme, web} from '#/alf' 26import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27import {Divider} from '#/components/Divider' 28import { 29 Bell_Filled_Corner0_Rounded as BellFilled, 30 Bell_Stroke2_Corner0_Rounded as Bell, 31} from '#/components/icons/Bell' 32import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 33import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList' 34import { 35 Hashtag_Filled_Corner0_Rounded as HashtagFilled, 36 Hashtag_Stroke2_Corner0_Rounded as Hashtag, 37} from '#/components/icons/Hashtag' 38import { 39 HomeOpen_Filled_Corner0_Rounded as HomeFilled, 40 HomeOpen_Stoke2_Corner0_Rounded as Home, 41} from '#/components/icons/HomeOpen' 42import { 43 MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled, 44 MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass, 45} from '#/components/icons/MagnifyingGlass' 46import { 47 Message_Stroke2_Corner0_Rounded as Message, 48 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, 49} from '#/components/icons/Message' 50import {SettingsGear2_Stroke2_Corner0_Rounded as Settings} from '#/components/icons/SettingsGear2' 51import { 52 UserCircle_Filled_Corner0_Rounded as UserCircleFilled, 53 UserCircle_Stroke2_Corner0_Rounded as UserCircle, 54} from '#/components/icons/UserCircle' 55import {InlineLinkText} from '#/components/Link' 56import {Text} from '#/components/Typography' 57import {useSimpleVerificationState} from '#/components/verification' 58import {VerificationCheck} from '#/components/verification/VerificationCheck' 59import {IS_WEB} from '#/env' 60import {useActorStatus} from '#/features/liveNow' 61 62const iconWidth = 26 63 64let DrawerProfileCard = ({ 65 account, 66 onPressProfile, 67}: { 68 account: SessionAccount 69 onPressProfile: () => void 70}): React.ReactNode => { 71 const {_, i18n} = useLingui() 72 const t = useTheme() 73 const {data: profile} = useProfileQuery({did: account.did}) 74 const verification = useSimpleVerificationState({profile}) 75 const {isActive: live} = useActorStatus(profile) 76 77 return ( 78 <TouchableOpacity 79 testID="profileCardButton" 80 accessibilityLabel={_(msg`Profile`)} 81 accessibilityHint={_(msg`Navigates to your profile`)} 82 onPress={onPressProfile} 83 style={[a.gap_sm, a.pr_lg]}> 84 <UserAvatar 85 size={52} 86 avatar={profile?.avatar} 87 // See https://github.com/bluesky-social/social-app/pull/1801: 88 usePlainRNImage={true} 89 type={profile?.associated?.labeler ? 'labeler' : 'user'} 90 live={live} 91 /> 92 <View style={[a.gap_2xs]}> 93 <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 94 <Text 95 emoji 96 style={[a.font_bold, a.text_xl, a.mt_2xs, a.leading_tight]} 97 numberOfLines={1}> 98 {profile?.displayName || account.handle} 99 </Text> 100 {verification.showBadge && ( 101 <View 102 style={{ 103 top: 0, 104 }}> 105 <VerificationCheck 106 width={16} 107 verifier={verification.role === 'verifier'} 108 /> 109 </View> 110 )} 111 </View> 112 <Text 113 emoji 114 style={[t.atoms.text_contrast_medium, a.text_md, a.leading_tight]} 115 numberOfLines={1}> 116 {sanitizeHandle(account.handle, '@')} 117 </Text> 118 </View> 119 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 120 <Trans> 121 <Text style={[a.text_md, a.font_semi_bold]}> 122 {formatCount(i18n, profile?.followersCount ?? 0)} 123 </Text>{' '} 124 <Plural 125 value={profile?.followersCount || 0} 126 one="follower" 127 other="followers" 128 /> 129 </Trans>{' '} 130 &middot;{' '} 131 <Trans> 132 <Text style={[a.text_md, a.font_semi_bold]}> 133 {formatCount(i18n, profile?.followsCount ?? 0)} 134 </Text>{' '} 135 <Plural 136 value={profile?.followsCount || 0} 137 one="following" 138 other="following" 139 /> 140 </Trans> 141 </Text> 142 </TouchableOpacity> 143 ) 144} 145DrawerProfileCard = React.memo(DrawerProfileCard) 146export {DrawerProfileCard} 147 148let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { 149 const t = useTheme() 150 const insets = useSafeAreaInsets() 151 const setDrawerOpen = useSetDrawerOpen() 152 const navigation = useNavigation<NavigationProp>() 153 const { 154 isAtHome, 155 isAtSearch, 156 isAtFeeds, 157 isAtBookmarks, 158 isAtNotifications, 159 isAtMyProfile, 160 isAtMessages, 161 } = useNavigationTabState() 162 const {hasSession, currentAccount} = useSession() 163 164 // events 165 // = 166 167 const onPressTab = React.useCallback( 168 (tab: 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile') => { 169 const state = navigation.getState() 170 setDrawerOpen(false) 171 if (IS_WEB) { 172 // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh 173 if (tab === 'MyProfile') { 174 navigation.navigate('Profile', {name: currentAccount!.handle}) 175 } else { 176 // @ts-expect-error struggles with string unions, apparently 177 navigation.navigate(tab) 178 } 179 } else { 180 const tabState = getTabState(state, tab) 181 if (tabState === TabState.InsideAtRoot) { 182 emitSoftReset() 183 } else if (tabState === TabState.Inside) { 184 // find the correct navigator in which to pop-to-top 185 const target = state.routes.find(route => route.name === `${tab}Tab`) 186 ?.state?.key 187 if (target) { 188 // if we found it, trigger pop-to-top 189 navigation.dispatch({ 190 ...StackActions.popToTop(), 191 target, 192 }) 193 } else { 194 // fallback: reset navigation 195 navigation.reset({ 196 index: 0, 197 routes: [{name: `${tab}Tab`}], 198 }) 199 } 200 } else { 201 navigation.navigate(`${tab}Tab`) 202 } 203 } 204 }, 205 [navigation, setDrawerOpen, currentAccount], 206 ) 207 208 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) 209 210 const onPressSearch = React.useCallback( 211 () => onPressTab('Search'), 212 [onPressTab], 213 ) 214 215 const onPressMessages = React.useCallback( 216 () => onPressTab('Messages'), 217 [onPressTab], 218 ) 219 220 const onPressNotifications = React.useCallback( 221 () => onPressTab('Notifications'), 222 [onPressTab], 223 ) 224 225 const onPressProfile = React.useCallback(() => { 226 onPressTab('MyProfile') 227 }, [onPressTab]) 228 229 const onPressMyFeeds = React.useCallback(() => { 230 navigation.navigate('Feeds') 231 setDrawerOpen(false) 232 }, [navigation, setDrawerOpen]) 233 234 const onPressLists = React.useCallback(() => { 235 navigation.navigate('Lists') 236 setDrawerOpen(false) 237 }, [navigation, setDrawerOpen]) 238 239 const onPressBookmarks = React.useCallback(() => { 240 navigation.navigate('Bookmarks') 241 setDrawerOpen(false) 242 }, [navigation, setDrawerOpen]) 243 244 const onPressSettings = React.useCallback(() => { 245 navigation.navigate('Settings') 246 setDrawerOpen(false) 247 }, [navigation, setDrawerOpen]) 248 249 const onPressFeedback = React.useCallback(() => { 250 Linking.openURL( 251 FEEDBACK_FORM_URL({ 252 email: currentAccount?.email, 253 handle: currentAccount?.handle, 254 }), 255 ) 256 }, [currentAccount]) 257 258 const onPressHelp = React.useCallback(() => { 259 Linking.openURL(HELP_DESK_URL) 260 }, []) 261 262 // rendering 263 // = 264 265 return ( 266 <View 267 testID="drawer" 268 style={[a.flex_1, a.border_r, t.atoms.bg, t.atoms.border_contrast_low]}> 269 <ScrollView 270 style={[a.flex_1]} 271 contentContainerStyle={[ 272 { 273 paddingTop: Math.max( 274 insets.top + a.pt_xl.paddingTop, 275 a.pt_xl.paddingTop, 276 ), 277 }, 278 ]}> 279 <View style={[a.px_xl]}> 280 {hasSession && currentAccount ? ( 281 <DrawerProfileCard 282 account={currentAccount} 283 onPressProfile={onPressProfile} 284 /> 285 ) : ( 286 <View style={[a.pr_xl]}> 287 <NavSignupCard /> 288 </View> 289 )} 290 291 <Divider style={[a.mt_xl, a.mb_sm]} /> 292 </View> 293 294 {hasSession ? ( 295 <> 296 <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} /> 297 <HomeMenuItem isActive={isAtHome} onPress={onPressHome} /> 298 <ChatMenuItem isActive={isAtMessages} onPress={onPressMessages} /> 299 <NotificationsMenuItem 300 isActive={isAtNotifications} 301 onPress={onPressNotifications} 302 /> 303 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> 304 <ListsMenuItem onPress={onPressLists} /> 305 <BookmarksMenuItem 306 isActive={isAtBookmarks} 307 onPress={onPressBookmarks} 308 /> 309 <ProfileMenuItem 310 isActive={isAtMyProfile} 311 onPress={onPressProfile} 312 /> 313 <SettingsMenuItem onPress={onPressSettings} /> 314 </> 315 ) : ( 316 <> 317 <HomeMenuItem isActive={isAtHome} onPress={onPressHome} /> 318 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> 319 <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} /> 320 </> 321 )} 322 323 <View style={[a.px_xl]}> 324 <Divider style={[a.mb_xl, a.mt_sm]} /> 325 <ExtraLinks /> 326 </View> 327 </ScrollView> 328 329 <DrawerFooter 330 onPressFeedback={onPressFeedback} 331 onPressHelp={onPressHelp} 332 /> 333 </View> 334 ) 335} 336DrawerContent = React.memo(DrawerContent) 337export {DrawerContent} 338 339let DrawerFooter = ({ 340 onPressFeedback, 341 onPressHelp, 342}: { 343 onPressFeedback: () => void 344 onPressHelp: () => void 345}): React.ReactNode => { 346 const {_} = useLingui() 347 const insets = useSafeAreaInsets() 348 return ( 349 <View 350 style={[ 351 a.flex_row, 352 a.gap_sm, 353 a.flex_wrap, 354 a.pl_xl, 355 a.pt_md, 356 { 357 paddingBottom: Math.max( 358 insets.bottom + tokens.space.xs, 359 tokens.space.xl, 360 ), 361 }, 362 ]}> 363 <Button 364 label={_(msg`Send feedback`)} 365 size="small" 366 variant="solid" 367 color="secondary" 368 onPress={onPressFeedback}> 369 <ButtonIcon icon={Message} position="left" /> 370 <ButtonText> 371 <Trans>Feedback</Trans> 372 </ButtonText> 373 </Button> 374 <Button 375 label={_(msg`Get help`)} 376 size="small" 377 variant="outline" 378 color="secondary" 379 onPress={onPressHelp} 380 style={{ 381 backgroundColor: 'transparent', 382 }}> 383 <ButtonText> 384 <Trans>Help</Trans> 385 </ButtonText> 386 </Button> 387 </View> 388 ) 389} 390DrawerFooter = React.memo(DrawerFooter) 391 392interface MenuItemProps extends ComponentProps<typeof PressableScale> { 393 icon: JSX.Element 394 label: string 395 count?: string 396 bold?: boolean 397} 398 399let SearchMenuItem = ({ 400 isActive, 401 onPress, 402}: { 403 isActive: boolean 404 onPress: () => void 405}): React.ReactNode => { 406 const {_} = useLingui() 407 const t = useTheme() 408 return ( 409 <MenuItem 410 icon={ 411 isActive ? ( 412 <MagnifyingGlassFilled style={[t.atoms.text]} width={iconWidth} /> 413 ) : ( 414 <MagnifyingGlass style={[t.atoms.text]} width={iconWidth} /> 415 ) 416 } 417 label={_(msg`Explore`)} 418 bold={isActive} 419 onPress={onPress} 420 /> 421 ) 422} 423SearchMenuItem = React.memo(SearchMenuItem) 424 425let HomeMenuItem = ({ 426 isActive, 427 onPress, 428}: { 429 isActive: boolean 430 onPress: () => void 431}): React.ReactNode => { 432 const {_} = useLingui() 433 const t = useTheme() 434 return ( 435 <MenuItem 436 icon={ 437 isActive ? ( 438 <HomeFilled style={[t.atoms.text]} width={iconWidth} /> 439 ) : ( 440 <Home style={[t.atoms.text]} width={iconWidth} /> 441 ) 442 } 443 label={_(msg`Home`)} 444 bold={isActive} 445 onPress={onPress} 446 /> 447 ) 448} 449HomeMenuItem = React.memo(HomeMenuItem) 450 451let ChatMenuItem = ({ 452 isActive, 453 onPress, 454}: { 455 isActive: boolean 456 onPress: () => void 457}): React.ReactNode => { 458 const {_} = useLingui() 459 const t = useTheme() 460 return ( 461 <MenuItem 462 icon={ 463 isActive ? ( 464 <MessageFilled style={[t.atoms.text]} width={iconWidth} /> 465 ) : ( 466 <Message style={[t.atoms.text]} width={iconWidth} /> 467 ) 468 } 469 label={_(msg`Chat`)} 470 bold={isActive} 471 onPress={onPress} 472 /> 473 ) 474} 475ChatMenuItem = React.memo(ChatMenuItem) 476 477let NotificationsMenuItem = ({ 478 isActive, 479 onPress, 480}: { 481 isActive: boolean 482 onPress: () => void 483}): React.ReactNode => { 484 const {_} = useLingui() 485 const t = useTheme() 486 const numUnreadNotifications = useUnreadNotifications() 487 return ( 488 <MenuItem 489 icon={ 490 isActive ? ( 491 <BellFilled style={[t.atoms.text]} width={iconWidth} /> 492 ) : ( 493 <Bell style={[t.atoms.text]} width={iconWidth} /> 494 ) 495 } 496 label={_(msg`Notifications`)} 497 accessibilityHint={ 498 numUnreadNotifications === '' 499 ? '' 500 : _( 501 plural(numUnreadNotifications ?? 0, { 502 one: '# unread item', 503 other: '# unread items', 504 }), 505 ) 506 } 507 count={numUnreadNotifications} 508 bold={isActive} 509 onPress={onPress} 510 /> 511 ) 512} 513NotificationsMenuItem = React.memo(NotificationsMenuItem) 514 515let FeedsMenuItem = ({ 516 isActive, 517 onPress, 518}: { 519 isActive: boolean 520 onPress: () => void 521}): React.ReactNode => { 522 const {_} = useLingui() 523 const t = useTheme() 524 return ( 525 <MenuItem 526 icon={ 527 isActive ? ( 528 <HashtagFilled width={iconWidth} style={[t.atoms.text]} /> 529 ) : ( 530 <Hashtag width={iconWidth} style={[t.atoms.text]} /> 531 ) 532 } 533 label={_(msg`Feeds`)} 534 bold={isActive} 535 onPress={onPress} 536 /> 537 ) 538} 539FeedsMenuItem = React.memo(FeedsMenuItem) 540 541let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => { 542 const {_} = useLingui() 543 const t = useTheme() 544 545 return ( 546 <MenuItem 547 icon={<List style={[t.atoms.text]} width={iconWidth} />} 548 label={_(msg`Lists`)} 549 onPress={onPress} 550 /> 551 ) 552} 553ListsMenuItem = React.memo(ListsMenuItem) 554 555let BookmarksMenuItem = ({ 556 isActive, 557 onPress, 558}: { 559 isActive: boolean 560 onPress: () => void 561}): React.ReactNode => { 562 const {_} = useLingui() 563 const t = useTheme() 564 565 return ( 566 <MenuItem 567 icon={ 568 isActive ? ( 569 <BookmarkFilled style={[t.atoms.text]} width={iconWidth} /> 570 ) : ( 571 <Bookmark style={[t.atoms.text]} width={iconWidth} /> 572 ) 573 } 574 label={_(msg({message: 'Saved', context: 'link to bookmarks screen'}))} 575 onPress={onPress} 576 /> 577 ) 578} 579BookmarksMenuItem = React.memo(BookmarksMenuItem) 580 581let ProfileMenuItem = ({ 582 isActive, 583 onPress, 584}: { 585 isActive: boolean 586 onPress: () => void 587}): React.ReactNode => { 588 const {_} = useLingui() 589 const t = useTheme() 590 return ( 591 <MenuItem 592 icon={ 593 isActive ? ( 594 <UserCircleFilled style={[t.atoms.text]} width={iconWidth} /> 595 ) : ( 596 <UserCircle style={[t.atoms.text]} width={iconWidth} /> 597 ) 598 } 599 label={_(msg`Profile`)} 600 onPress={onPress} 601 /> 602 ) 603} 604ProfileMenuItem = React.memo(ProfileMenuItem) 605 606let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => { 607 const {_} = useLingui() 608 const t = useTheme() 609 return ( 610 <MenuItem 611 icon={<Settings style={[t.atoms.text]} width={iconWidth} />} 612 label={_(msg`Settings`)} 613 onPress={onPress} 614 /> 615 ) 616} 617SettingsMenuItem = React.memo(SettingsMenuItem) 618 619function MenuItem({icon, label, count, bold, onPress}: MenuItemProps) { 620 const t = useTheme() 621 return ( 622 <Button 623 testID={`menuItemButton-${label}`} 624 onPress={onPress} 625 accessibilityRole="tab" 626 label={label}> 627 {({hovered, pressed}) => ( 628 <View 629 style={[ 630 a.flex_1, 631 a.flex_row, 632 a.align_center, 633 a.gap_md, 634 a.py_md, 635 a.px_xl, 636 (hovered || pressed) && t.atoms.bg_contrast_25, 637 ]}> 638 <View style={[a.relative]}> 639 {icon} 640 {count ? ( 641 <View 642 style={[ 643 a.absolute, 644 a.inset_0, 645 a.align_end, 646 {top: -4, right: a.gap_sm.gap * -1}, 647 ]}> 648 <View 649 style={[ 650 a.rounded_full, 651 { 652 right: count.length === 1 ? 6 : 0, 653 paddingHorizontal: 4, 654 paddingVertical: 1, 655 backgroundColor: t.palette.primary_500, 656 }, 657 ]}> 658 <Text 659 style={[ 660 a.text_xs, 661 a.leading_tight, 662 a.font_semi_bold, 663 { 664 fontVariant: ['tabular-nums'], 665 color: colors.white, 666 }, 667 ]} 668 numberOfLines={1}> 669 {count} 670 </Text> 671 </View> 672 </View> 673 ) : undefined} 674 </View> 675 <Text 676 style={[ 677 a.flex_1, 678 a.text_2xl, 679 bold && a.font_bold, 680 web(a.leading_snug), 681 ]} 682 numberOfLines={1}> 683 {label} 684 </Text> 685 </View> 686 )} 687 </Button> 688 ) 689} 690 691function ExtraLinks() { 692 const {_} = useLingui() 693 const t = useTheme() 694 const kawaii = useKawaiiMode() 695 696 return ( 697 <View style={[a.flex_col, a.gap_md, a.flex_wrap]}> 698 <InlineLinkText 699 style={[a.text_md]} 700 label={_(msg`Terms of Service`)} 701 to="https://bsky.social/about/support/tos"> 702 <Trans>Terms of Service</Trans> 703 </InlineLinkText> 704 <InlineLinkText 705 style={[a.text_md]} 706 to="https://bsky.social/about/support/privacy-policy" 707 label={_(msg`Privacy Policy`)}> 708 <Trans>Privacy Policy</Trans> 709 </InlineLinkText> 710 {kawaii && ( 711 <Text style={t.atoms.text_contrast_medium}> 712 <Trans> 713 Logo by{' '} 714 <InlineLinkText 715 style={[a.text_md]} 716 to="/profile/sawaratsuki.bsky.social" 717 label="@sawaratsuki.bsky.social"> 718 @sawaratsuki.bsky.social 719 </InlineLinkText> 720 </Trans> 721 </Text> 722 )} 723 </View> 724 ) 725}