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