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

Configure Feed

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

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