forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 ·{' '}
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}