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