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