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

Configure Feed

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

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