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