this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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