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