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