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

Configure Feed

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

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