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