Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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