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

Configure Feed

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

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