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