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