Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
25 kB · 873 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874import {type ComponentProps, type JSX, memo, useCallback} from 'react'import { Linking, Pressable, ScrollView, TouchableOpacity, View,} from 'react-native'import {useSafeAreaInsets} from 'react-native-safe-area-context'import {msg, plural} from '@lingui/core/macro'import {useLingui} from '@lingui/react'import {Plural, Trans} from '@lingui/react/macro'import {StackActions, useNavigation} from '@react-navigation/native'
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'import {type PressableScale} from '#/lib/custom-animations/PressableScale'import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'import { formatCountsMetricNumber, shouldShowCountsMetricLabelOnly, shouldShowProfileCountsMetric,} from '#/lib/metrics-display'import {getTabState, TabState} from '#/lib/routes/helpers'import {type SharedNavTab, TAB_TO_NAV_ITEM} from '#/lib/routes/tab-to-nav-item'import {type NavigationProp} from '#/lib/routes/types'import {getShapeStyle} from '#/lib/shapes'import {sanitizeHandle} from '#/lib/strings/handles'import {emitSoftReset} from '#/state/events'import {badgeText, useBadgePreference} from '#/state/preferences/badge-text'import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'import { useFollowersMetricsDisplay, useFollowingMetricsDisplay, useNotificationsTabBadgeDisplay,} from '#/state/preferences/metrics-display-preference'import {useUnreadNotifications} from '#/state/queries/notifications/unread'import {useProfileQuery} from '#/state/queries/profile'import {type SessionAccount, useSession} from '#/state/session'import {useSetDrawerOpen, useThemePrefs} from '#/state/shell'import {UserAvatar} from '#/view/com/util/UserAvatar'import {useLogoVariant} from '#/view/icons/useLogoVariant'import {NavSignupCard} from '#/view/shell/NavSignupCard'import {atoms as a, tokens, useTheme, web} from '#/alf'import {Button, ButtonIcon, ButtonText} from '#/components/Button'import {useDialogControl} from '#/components/Dialog'import {Divider} from '#/components/Divider'import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRight} from '#/components/icons/ArrowShareRight'import { Bell_Filled_Corner0_Rounded as BellFilled, Bell_Stroke2_Corner0_Rounded as Bell,} from '#/components/icons/Bell'import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'import { Hashtag_Filled_Corner0_Rounded as HashtagFilled, Hashtag_Stroke2_Corner0_Rounded as Hashtag,} from '#/components/icons/Hashtag'import { HomeOpen_Filled_Corner0_Rounded as HomeFilled, HomeOpen_Stoke2_Corner0_Rounded as Home,} from '#/components/icons/HomeOpen'import { MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled, MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass,} from '#/components/icons/MagnifyingGlass'import { Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,} from '#/components/icons/Message'import {SettingsGear2_Stroke2_Corner0_Rounded as Settings} from '#/components/icons/SettingsGear2'import { UserCircle_Filled_Corner0_Rounded as UserCircleFilled, UserCircle_Stroke2_Corner0_Rounded as UserCircle,} from '#/components/icons/UserCircle'import {InlineLinkText} from '#/components/Link'import {OTAChannelNotice} from '#/components/OTAChannelNotice'import {ProfileBadges} from '#/components/ProfileBadges'import {Text} from '#/components/Typography'import {useAnalytics} from '#/analytics'import {IS_NATIVE, IS_WEB} from '#/env'import {InviteFriendsDialog} from '#/features/inviteFriends'import {useActorStatus} from '#/features/liveNow'import {accentForeground} from '#/features/themes/accentForeground'import {useActiveThemeUpdate} from '#/features/themes/api'
const iconWidth = 26
let DrawerProfileCard = ({ account, onPressProfile, onPressShare,}: { account: SessionAccount onPressProfile: () => void onPressShare?: () => void}): React.ReactNode => { const {_, i18n} = useLingui() const t = useTheme() const {data: profile} = useProfileQuery({did: account.did}) const {isActive: live} = useActorStatus(profile)
const followersMetricsDisplay = useFollowersMetricsDisplay() const followingMetricsDisplay = useFollowingMetricsDisplay() const followersCount = profile?.followersCount ?? 0 const followingCount = profile?.followsCount ?? 0 const showFollowers = shouldShowProfileCountsMetric( followersMetricsDisplay, followersCount, ) const showFollowing = shouldShowProfileCountsMetric( followingMetricsDisplay, followingCount, )
return ( <TouchableOpacity testID="profileCardButton" accessibilityLabel={_(msg`Profile`)} accessibilityHint={_(msg`Navigates to your profile`)} onPress={onPressProfile} style={[a.gap_sm, a.pr_lg]}> <UserAvatar size={52} avatar={profile?.avatar} // See https://github.com/bluesky-social/social-app/pull/1801: usePlainRNImage={true} type={profile?.associated?.labeler ? 'labeler' : 'user'} live={live} /> <View style={[a.gap_2xs]}> <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> <Text emoji style={[ a.font_bold, a.text_xl, a.mt_2xs, a.leading_tight, a.flex_shrink, ]} numberOfLines={1}> {profile?.displayName || account.handle} </Text> {profile && <ProfileBadges profile={profile} size="lg" />} {onPressShare && ( <Pressable accessibilityRole="button" accessibilityLabel={_(msg`Invite friends`)} accessibilityHint={_( msg`Opens the invite friends sheet to share your profile`, )} onPress={onPressShare} hitSlop={8} style={({pressed}) => [ a.ml_auto, { width: 32, height: 32, borderRadius: 16, backgroundColor: t.palette.contrast_50, alignItems: 'center', justifyContent: 'center', opacity: pressed ? 0.7 : 1, }, ]}> <ArrowShareRight width={16} height={16} fill={t.palette.primary_500} /> </Pressable> )} </View> <Text emoji style={[t.atoms.text_contrast_medium, a.text_md, a.leading_tight]} numberOfLines={1}> {sanitizeHandle(account.handle, '@')} </Text> </View> {!showFollowers && !showFollowing ? null : ( <Text style={[a.text_md, t.atoms.text_contrast_medium]}> {showFollowers ? ( <Trans> {!shouldShowCountsMetricLabelOnly( followersMetricsDisplay, followersCount, ) ? ( <Text style={[a.text_md, a.font_semi_bold]}> {formatCountsMetricNumber( i18n, followersMetricsDisplay, followersCount, )} </Text> ) : null}{' '} <Plural value={followersCount} one="follower" other="followers" /> </Trans> ) : null} {showFollowers && showFollowing ? ( <Text style={[a.text_md, t.atoms.text_contrast_medium]}> {' '} ·{' '} </Text> ) : null} {showFollowing ? ( <Trans> {!shouldShowCountsMetricLabelOnly( followingMetricsDisplay, followingCount, ) ? ( <Text style={[a.text_md, a.font_semi_bold]}> {formatCountsMetricNumber( i18n, followingMetricsDisplay, followingCount, )} </Text> ) : null}{' '} <Plural value={followingCount} one="following" other="following" /> </Trans> ) : null} </Text> )} </TouchableOpacity> )}DrawerProfileCard = memo(DrawerProfileCard)export {DrawerProfileCard}
let DrawerContent = ({}: React.PropsWithoutRef<{}>): React.ReactNode => { const t = useTheme() const insets = useSafeAreaInsets() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() const ax = useAnalytics() const { isAtHome, isAtSearch, isAtFeeds, isAtBookmarks, isAtNotifications, isAtMyProfile, isAtMessages, } = useNavigationTabState() const {hasSession, currentAccount} = useSession() const inviteFriendsControl = useDialogControl()
// events // =
const onPressTab = useCallback( (tab: SharedNavTab, surface: 'drawer' | 'drawerHeader' = 'drawer') => { ax.metric('nav:click', { item: TAB_TO_NAV_ITEM[tab], surface, }) const state = navigation.getState() setDrawerOpen(false) if (IS_WEB) { // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh if (tab === 'MyProfile') { navigation.navigate('Profile', {name: currentAccount!.handle}) } else { // @ts-expect-error struggles with string unions, apparently navigation.navigate(tab) } } else { const tabState = getTabState(state, tab) if (tabState === TabState.InsideAtRoot) { emitSoftReset() } else if (tabState === TabState.Inside) { // find the correct navigator in which to pop-to-top const target = state.routes.find(route => route.name === `${tab}Tab`) ?.state?.key if (target) { // if we found it, trigger pop-to-top navigation.dispatch({ ...StackActions.popToTop(), target, }) } else { // fallback: reset navigation navigation.reset({ index: 0, routes: [{name: `${tab}Tab`}], }) } } else { navigation.navigate(`${tab}Tab`) } } }, [navigation, setDrawerOpen, currentAccount, ax], )
const onPressHome = useCallback(() => onPressTab('Home'), [onPressTab])
const onPressSearch = useCallback(() => onPressTab('Search'), [onPressTab])
const onPressMessages = useCallback( () => onPressTab('Messages'), [onPressTab], )
const onPressNotifications = useCallback( () => onPressTab('Notifications'), [onPressTab], )
const onPressProfile = useCallback(() => { onPressTab('MyProfile') }, [onPressTab])
const onPressDrawerHeaderProfile = useCallback(() => { onPressTab('MyProfile', 'drawerHeader') }, [onPressTab])
const onPressMyFeeds = useCallback(() => { ax.metric('nav:click', {item: 'feeds', surface: 'drawer'}) navigation.navigate('Feeds') setDrawerOpen(false) }, [navigation, setDrawerOpen, ax])
const onPressLists = useCallback(() => { ax.metric('nav:click', {item: 'lists', surface: 'drawer'}) navigation.navigate('Lists') setDrawerOpen(false) }, [navigation, setDrawerOpen, ax])
const onPressBookmarks = useCallback(() => { ax.metric('nav:click', {item: 'saved', surface: 'drawer'}) navigation.navigate('Bookmarks') setDrawerOpen(false) }, [navigation, setDrawerOpen, ax])
const onPressSettings = useCallback(() => { ax.metric('nav:click', {item: 'settings', surface: 'drawer'}) navigation.navigate('Settings') setDrawerOpen(false) }, [navigation, setDrawerOpen, ax])
const onPressFeedback = useCallback(() => { void Linking.openURL( FEEDBACK_FORM_URL({ email: currentAccount?.email, handle: currentAccount?.handle, }), ) }, [currentAccount])
const onPressHelp = useCallback(() => { void Linking.openURL(HELP_DESK_URL) }, [])
// rendering // =
return ( <View testID="drawer" style={[a.flex_1, a.border_r, t.atoms.bg, t.atoms.border_contrast_low]}> <ScrollView style={[a.flex_1]} contentContainerStyle={[ { paddingTop: Math.max( insets.top + a.pt_xl.paddingTop, a.pt_xl.paddingTop, ), }, ]}> <View style={[a.px_xl]}> {hasSession && currentAccount ? ( <DrawerProfileCard account={currentAccount} onPressProfile={onPressDrawerHeaderProfile} onPressShare={ IS_NATIVE ? () => { ax.metric('invite:dialog:open', {logContext: 'Drawer'}) setDrawerOpen(false) inviteFriendsControl.open() } : undefined } /> ) : ( <View style={[a.pr_xl]}> <NavSignupCard /> </View> )}
<OTAChannelNotice style={[a.mt_lg]} />
<Divider style={[a.mt_xl, a.mb_sm]} /> </View>
{hasSession ? ( <> <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} /> <HomeMenuItem isActive={isAtHome} onPress={onPressHome} /> <ChatMenuItem isActive={isAtMessages} onPress={onPressMessages} /> <NotificationsMenuItem isActive={isAtNotifications} onPress={onPressNotifications} /> <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> <ListsMenuItem onPress={onPressLists} /> <BookmarksMenuItem isActive={isAtBookmarks} onPress={onPressBookmarks} /> <ProfileMenuItem isActive={isAtMyProfile} onPress={onPressProfile} /> <SettingsMenuItem onPress={onPressSettings} /> </> ) : ( <> <HomeMenuItem isActive={isAtHome} onPress={onPressHome} /> <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} /> </> )}
<View style={[a.px_xl]}> <Divider style={[a.mb_xl, a.mt_sm]} /> <ExtraLinks /> </View> </ScrollView>
<DrawerFooter onPressFeedback={onPressFeedback} onPressHelp={onPressHelp} /> <InviteFriendsDialog control={inviteFriendsControl} /> </View> )}DrawerContent = memo(DrawerContent)export {DrawerContent}
let DrawerFooter = ({ onPressFeedback, onPressHelp,}: { onPressFeedback: () => void onPressHelp: () => void}): React.ReactNode => { const {_} = useLingui() const insets = useSafeAreaInsets() return ( <View style={[ a.flex_row, a.gap_sm, a.flex_wrap, a.pl_xl, a.pt_md, { paddingBottom: Math.max( insets.bottom + tokens.space.xs, tokens.space.xl, ), }, ]}> <Button label={_(msg`Send feedback`)} size="small" variant="solid" color="secondary" onPress={onPressFeedback}> <ButtonIcon icon={Message} position="left" /> <ButtonText> <Trans>Feedback</Trans> </ButtonText> </Button> <Button label={_(msg`Visit code repository`)} size="small" variant="outline" color="secondary" onPress={onPressHelp} style={{ backgroundColor: 'transparent', }}> <ButtonText> <Trans>Code</Trans> </ButtonText> </Button> </View> )}DrawerFooter = memo(DrawerFooter)
interface MenuItemProps extends ComponentProps<typeof PressableScale> { icon: JSX.Element label: string count?: string hasNew?: boolean bold?: boolean}
let SearchMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() return ( <MenuItem icon={ isActive ? ( <MagnifyingGlassFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <MagnifyingGlass style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg`Explore`)} bold={isActive} onPress={onPress} /> )}SearchMenuItem = memo(SearchMenuItem)
let HomeMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() return ( <MenuItem icon={ isActive ? ( <HomeFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <Home style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg`Home`)} bold={isActive} onPress={onPress} /> )}HomeMenuItem = memo(HomeMenuItem)
let ChatMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() return ( <MenuItem icon={ isActive ? ( <MessageFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <Message style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg`Chat`)} bold={isActive} onPress={onPress} /> )}ChatMenuItem = memo(ChatMenuItem)
let NotificationsMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() const numUnreadNotifications = useUnreadNotifications() const [notificationsCustomText] = useBadgePreference('notificationsBadgeText') const notificationsTabBadgeDisplay = useNotificationsTabBadgeDisplay() const notificationsText = notificationsTabBadgeDisplay === 'text' ? notificationsCustomText : undefined return ( <MenuItem icon={ isActive ? ( <BellFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <Bell style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg`Notifications`)} accessibilityHint={ numUnreadNotifications === '' ? '' : _( plural(numUnreadNotifications ?? 0, { one: '# unread item', other: '# unread items', }), ) } count={ notificationsTabBadgeDisplay === 'exact' || notificationsTabBadgeDisplay === 'text' ? badgeText(numUnreadNotifications, notificationsText) : undefined } hasNew={ notificationsTabBadgeDisplay === 'visible' && numUnreadNotifications !== '' } bold={isActive} onPress={onPress} /> )}NotificationsMenuItem = memo(NotificationsMenuItem)
let FeedsMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() return ( <MenuItem icon={ isActive ? ( <HashtagFilled width={iconWidth} style={[t.atoms.text]} /> ) : ( <Hashtag width={iconWidth} style={[t.atoms.text]} /> ) } label={_(msg`Feeds`)} bold={isActive} onPress={onPress} /> )}FeedsMenuItem = memo(FeedsMenuItem)
let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme()
return ( <MenuItem icon={<List style={[t.atoms.text]} width={iconWidth} />} label={_(msg`Lists`)} onPress={onPress} /> )}ListsMenuItem = memo(ListsMenuItem)
let BookmarksMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme()
return ( <MenuItem icon={ isActive ? ( <BookmarkFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <Bookmark style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg({message: 'Saved', context: 'link to bookmarks screen'}))} onPress={onPress} /> )}BookmarksMenuItem = memo(BookmarksMenuItem)
let ProfileMenuItem = ({ isActive, onPress,}: { isActive: boolean onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() return ( <MenuItem icon={ isActive ? ( <UserCircleFilled style={[t.atoms.text]} width={iconWidth} /> ) : ( <UserCircle style={[t.atoms.text]} width={iconWidth} /> ) } label={_(msg`Profile`)} onPress={onPress} /> )}ProfileMenuItem = memo(ProfileMenuItem)
let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => { const {_} = useLingui() const t = useTheme() const {activeTheme} = useThemePrefs() const themeUpdate = useActiveThemeUpdate(activeTheme) return ( <MenuItem icon={<Settings style={[t.atoms.text]} width={iconWidth} />} label={_(msg`Settings`)} hasNew={Boolean(themeUpdate)} onPress={onPress} /> )}SettingsMenuItem = memo(SettingsMenuItem)
function MenuItem({icon, label, count, hasNew, bold, onPress}: MenuItemProps) { const t = useTheme() const enableSquareButtons = useEnableSquareButtons() return ( <Button testID={`menuItemButton-${label}`} onPress={onPress} accessibilityRole="tab" label={label}> {({hovered, pressed}) => ( <View style={[ a.flex_1, a.flex_row, a.align_center, a.gap_md, a.py_md, a.px_xl, (hovered || pressed) && t.atoms.bg_contrast_25, ]}> <View style={[a.relative]}> {icon} {count ? ( <View style={[ a.absolute, a.inset_0, a.align_end, {top: -4, right: a.gap_sm.gap * -1}, ]}> <View style={[ getShapeStyle( enableSquareButtons, a.rounded_sm, a.rounded_full, ), { right: count.length === 1 ? 6 : 0, paddingHorizontal: 4, paddingVertical: 1, backgroundColor: t.palette.primary_500, }, ]}> <Text style={[ a.text_xs, a.leading_tight, a.font_semi_bold, { fontVariant: ['tabular-nums'], color: accentForeground(t, t.palette.primary_500), }, ]} numberOfLines={1}> {count} </Text> </View> </View> ) : hasNew ? ( <View style={[ a.absolute, a.rounded_full, { top: -2, right: -2, width: 8, height: 8, backgroundColor: t.palette.primary_500, }, ]} /> ) : undefined} </View> <Text style={[ a.flex_1, a.text_2xl, bold && a.font_bold, web(a.leading_snug), ]} numberOfLines={1}> {label} </Text> </View> )} </Button> )}
function ExtraLinks() { const {_} = useLingui() const t = useTheme() const logoVariant = useLogoVariant()
return ( <View style={[a.flex_col, a.gap_md, a.flex_wrap]}> <InlineLinkText style={[a.text_md]} label={_(msg`Terms of Service`)} to="https://witchsky.app/about/tos"> <Trans>Terms of Service</Trans> </InlineLinkText> <InlineLinkText style={[a.text_md]} to="https://witchsky.app/about/privacy" label={_(msg`Privacy Policy`)}> <Trans>Privacy Policy</Trans> </InlineLinkText> {logoVariant === 'kawaii' && ( <Text style={t.atoms.text_contrast_medium}> <Trans> Kawaii logo by{' '} <InlineLinkText style={[a.text_md]} to="https://ovvie.neocities.org/" label="ovvie"> ovvie </InlineLinkText> </Trans> </Text> )} </View> )}