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