mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {ComponentProps} from 'react'
2import {
3 Linking,
4 SafeAreaView,
5 ScrollView,
6 StyleProp,
7 StyleSheet,
8 TouchableOpacity,
9 View,
10 ViewStyle,
11} from 'react-native'
12import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
13import {msg, Plural, Trans} from '@lingui/macro'
14import {useLingui} from '@lingui/react'
15import {StackActions, useNavigation} from '@react-navigation/native'
16
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 {SessionAccount, useSession} from '#/state/session'
22import {useSetDrawerOpen} from '#/state/shell'
23import {useAnalytics} from 'lib/analytics/analytics'
24import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants'
25import {useNavigationTabState} from 'lib/hooks/useNavigationTabState'
26import {usePalette} from 'lib/hooks/usePalette'
27import {getTabState, TabState} from 'lib/routes/helpers'
28import {NavigationProp} from 'lib/routes/types'
29import {colors, s} from 'lib/styles'
30import {useTheme} from 'lib/ThemeContext'
31import {isWeb} from 'platform/detection'
32import {NavSignupCard} from '#/view/shell/NavSignupCard'
33import {formatCount} from 'view/com/util/numeric/format'
34import {Text} from 'view/com/util/text/Text'
35import {UserAvatar} from 'view/com/util/UserAvatar'
36import {atoms as a} from '#/alf'
37import {useTheme as useAlfTheme} from '#/alf'
38import {Button, ButtonIcon, ButtonText} from '#/components/Button'
39import {
40 Bell_Filled_Corner0_Rounded as BellFilled,
41 Bell_Stroke2_Corner0_Rounded as Bell,
42} from '#/components/icons/Bell'
43import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList'
44import {
45 Hashtag_Filled_Corner0_Rounded as HashtagFilled,
46 Hashtag_Stroke2_Corner0_Rounded as Hashtag,
47} from '#/components/icons/Hashtag'
48import {
49 HomeOpen_Filled_Corner0_Rounded as HomeFilled,
50 HomeOpen_Stoke2_Corner0_Rounded as Home,
51} from '#/components/icons/HomeOpen'
52import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
53import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
54import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
55import {SettingsGear2_Stroke2_Corner0_Rounded as Settings} from '#/components/icons/SettingsGear2'
56import {
57 UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
58 UserCircle_Stroke2_Corner0_Rounded as UserCircle,
59} from '#/components/icons/UserCircle'
60import {TextLink} from '../com/util/Link'
61
62const iconWidth = 28
63
64let DrawerProfileCard = ({
65 account,
66 onPressProfile,
67}: {
68 account: SessionAccount
69 onPressProfile: () => void
70}): React.ReactNode => {
71 const {_, i18n} = useLingui()
72 const pal = usePalette('default')
73 const {data: profile} = useProfileQuery({did: account.did})
74
75 return (
76 <TouchableOpacity
77 testID="profileCardButton"
78 accessibilityLabel={_(msg`Profile`)}
79 accessibilityHint={_(msg`Navigates to your profile`)}
80 onPress={onPressProfile}>
81 <UserAvatar
82 size={80}
83 avatar={profile?.avatar}
84 // See https://github.com/bluesky-social/social-app/pull/1801:
85 usePlainRNImage={true}
86 type={profile?.associated?.labeler ? 'labeler' : 'user'}
87 />
88 <Text
89 type="title-lg"
90 style={[pal.text, s.bold, styles.profileCardDisplayName]}
91 numberOfLines={1}>
92 {profile?.displayName || account.handle}
93 </Text>
94 <Text
95 type="2xl"
96 style={[pal.textLight, styles.profileCardHandle]}
97 numberOfLines={1}>
98 @{account.handle}
99 </Text>
100 <View
101 style={[
102 styles.profileCardFollowers,
103 a.gap_xs,
104 a.flex_row,
105 a.align_center,
106 a.flex_wrap,
107 ]}>
108 <Text type="xl" style={pal.textLight}>
109 <Trans>
110 <Text type="xl-medium" style={pal.text}>
111 {formatCount(i18n, profile?.followersCount ?? 0)}
112 </Text>{' '}
113 <Plural
114 value={profile?.followersCount || 0}
115 one="follower"
116 other="followers"
117 />
118 </Trans>
119 </Text>
120 <Text type="xl" style={pal.textLight}>
121 ·
122 </Text>
123 <Text type="xl" style={pal.textLight}>
124 <Trans>
125 <Text type="xl-medium" style={pal.text}>
126 {formatCount(i18n, profile?.followsCount ?? 0)}
127 </Text>{' '}
128 <Plural
129 value={profile?.followsCount || 0}
130 one="following"
131 other="following"
132 />
133 </Trans>
134 </Text>
135 </View>
136 </TouchableOpacity>
137 )
138}
139DrawerProfileCard = React.memo(DrawerProfileCard)
140export {DrawerProfileCard}
141
142let DrawerContent = ({}: {}): React.ReactNode => {
143 const theme = useTheme()
144 const t = useAlfTheme()
145 const pal = usePalette('default')
146 const {_} = useLingui()
147 const setDrawerOpen = useSetDrawerOpen()
148 const navigation = useNavigation<NavigationProp>()
149 const {track} = useAnalytics()
150 const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
151 useNavigationTabState()
152 const {hasSession, currentAccount} = useSession()
153 const kawaii = useKawaiiMode()
154
155 // events
156 // =
157
158 const onPressTab = React.useCallback(
159 (tab: string) => {
160 track('Menu:ItemClicked', {url: tab})
161 const state = navigation.getState()
162 setDrawerOpen(false)
163 if (isWeb) {
164 // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh
165 if (tab === 'MyProfile') {
166 navigation.navigate('Profile', {name: currentAccount!.handle})
167 } else {
168 // @ts-ignore must be Home, Search, Notifications, or MyProfile
169 navigation.navigate(tab)
170 }
171 } else {
172 const tabState = getTabState(state, tab)
173 if (tabState === TabState.InsideAtRoot) {
174 emitSoftReset()
175 } else if (tabState === TabState.Inside) {
176 navigation.dispatch(StackActions.popToTop())
177 } else {
178 // @ts-ignore must be Home, Search, Notifications, or MyProfile
179 navigation.navigate(`${tab}Tab`)
180 }
181 }
182 },
183 [track, navigation, setDrawerOpen, currentAccount],
184 )
185
186 const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
187
188 const onPressSearch = React.useCallback(
189 () => onPressTab('Search'),
190 [onPressTab],
191 )
192
193 const onPressNotifications = React.useCallback(
194 () => onPressTab('Notifications'),
195 [onPressTab],
196 )
197
198 const onPressProfile = React.useCallback(() => {
199 onPressTab('MyProfile')
200 }, [onPressTab])
201
202 const onPressMyFeeds = React.useCallback(() => {
203 track('Menu:ItemClicked', {url: 'Feeds'})
204 navigation.navigate('Feeds')
205 setDrawerOpen(false)
206 }, [navigation, setDrawerOpen, track])
207
208 const onPressLists = React.useCallback(() => {
209 track('Menu:ItemClicked', {url: 'Lists'})
210 navigation.navigate('Lists')
211 setDrawerOpen(false)
212 }, [navigation, track, setDrawerOpen])
213
214 const onPressSettings = React.useCallback(() => {
215 track('Menu:ItemClicked', {url: 'Settings'})
216 navigation.navigate('Settings')
217 setDrawerOpen(false)
218 }, [navigation, track, setDrawerOpen])
219
220 const onPressFeedback = React.useCallback(() => {
221 track('Menu:FeedbackClicked')
222 Linking.openURL(
223 FEEDBACK_FORM_URL({
224 email: currentAccount?.email,
225 handle: currentAccount?.handle,
226 }),
227 )
228 }, [track, currentAccount])
229
230 const onPressHelp = React.useCallback(() => {
231 track('Menu:HelpClicked')
232 Linking.openURL(HELP_DESK_URL)
233 }, [track])
234
235 // rendering
236 // =
237
238 return (
239 <View
240 testID="drawer"
241 style={[
242 styles.view,
243 theme.colorScheme === 'light' ? pal.view : t.atoms.bg_contrast_25,
244 ]}>
245 <SafeAreaView style={s.flex1}>
246 <ScrollView style={styles.main}>
247 {hasSession && currentAccount ? (
248 <View style={{}}>
249 <DrawerProfileCard
250 account={currentAccount}
251 onPressProfile={onPressProfile}
252 />
253 </View>
254 ) : (
255 <View style={{paddingRight: 20}}>
256 <NavSignupCard />
257 </View>
258 )}
259
260 {hasSession ? (
261 <>
262 <View style={{height: 16}} />
263 <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
264 <HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
265 <NotificationsMenuItem
266 isActive={isAtNotifications}
267 onPress={onPressNotifications}
268 />
269 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
270 <ListsMenuItem onPress={onPressLists} />
271 <ProfileMenuItem
272 isActive={isAtMyProfile}
273 onPress={onPressProfile}
274 />
275 <SettingsMenuItem onPress={onPressSettings} />
276 </>
277 ) : (
278 <>
279 <HomeMenuItem isActive={isAtHome} onPress={onPressHome} />
280 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} />
281 <SearchMenuItem isActive={isAtSearch} onPress={onPressSearch} />
282 </>
283 )}
284
285 <View style={styles.smallSpacer} />
286
287 <View style={[{flexWrap: 'wrap', gap: 12}, s.flexCol]}>
288 <TextLink
289 type="md"
290 style={pal.link}
291 href="https://bsky.social/about/support/tos"
292 text={_(msg`Terms of Service`)}
293 />
294 <TextLink
295 type="md"
296 style={pal.link}
297 href="https://bsky.social/about/support/privacy-policy"
298 text={_(msg`Privacy Policy`)}
299 />
300 {kawaii && (
301 <Text type="md" style={pal.textLight}>
302 Logo by{' '}
303 <TextLink
304 type="md"
305 href="/profile/sawaratsuki.bsky.social"
306 text="@sawaratsuki.bsky.social"
307 style={pal.link}
308 />
309 </Text>
310 )}
311 </View>
312
313 <View style={styles.smallSpacer} />
314 <View style={styles.smallSpacer} />
315 </ScrollView>
316
317 <DrawerFooter
318 onPressFeedback={onPressFeedback}
319 onPressHelp={onPressHelp}
320 />
321 </SafeAreaView>
322 </View>
323 )
324}
325DrawerContent = React.memo(DrawerContent)
326export {DrawerContent}
327
328let DrawerFooter = ({
329 onPressFeedback,
330 onPressHelp,
331}: {
332 onPressFeedback: () => void
333 onPressHelp: () => void
334}): React.ReactNode => {
335 const {_} = useLingui()
336 return (
337 <View style={styles.footer}>
338 <Button
339 label={_(msg`Send feedback`)}
340 size="small"
341 variant="solid"
342 color="secondary"
343 onPress={onPressFeedback}>
344 <ButtonIcon icon={Message} position="left" />
345 <ButtonText>
346 <Trans>Feedback</Trans>
347 </ButtonText>
348 </Button>
349 <Button
350 label={_(msg`Get help`)}
351 size="small"
352 variant="outline"
353 color="secondary"
354 onPress={onPressHelp}
355 style={{
356 backgroundColor: 'transparent',
357 }}>
358 <ButtonText>
359 <Trans>Help</Trans>
360 </ButtonText>
361 </Button>
362 </View>
363 )
364}
365DrawerFooter = React.memo(DrawerFooter)
366
367interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> {
368 icon: JSX.Element
369 label: string
370 count?: string
371 bold?: boolean
372}
373
374let SearchMenuItem = ({
375 isActive,
376 onPress,
377}: {
378 isActive: boolean
379 onPress: () => void
380}): React.ReactNode => {
381 const {_} = useLingui()
382 const pal = usePalette('default')
383 return (
384 <MenuItem
385 icon={
386 isActive ? (
387 <MagnifyingGlassFilled
388 style={pal.text as StyleProp<ViewStyle>}
389 width={iconWidth}
390 />
391 ) : (
392 <MagnifyingGlass
393 style={pal.text as StyleProp<ViewStyle>}
394 width={iconWidth}
395 />
396 )
397 }
398 label={_(msg`Search`)}
399 accessibilityLabel={_(msg`Search`)}
400 accessibilityHint=""
401 bold={isActive}
402 onPress={onPress}
403 />
404 )
405}
406SearchMenuItem = React.memo(SearchMenuItem)
407
408let HomeMenuItem = ({
409 isActive,
410 onPress,
411}: {
412 isActive: boolean
413 onPress: () => void
414}): React.ReactNode => {
415 const {_} = useLingui()
416 const pal = usePalette('default')
417 return (
418 <MenuItem
419 icon={
420 isActive ? (
421 <HomeFilled
422 style={pal.text as StyleProp<ViewStyle>}
423 width={iconWidth}
424 />
425 ) : (
426 <Home style={pal.text as StyleProp<ViewStyle>} width={iconWidth} />
427 )
428 }
429 label={_(msg`Home`)}
430 accessibilityLabel={_(msg`Home`)}
431 accessibilityHint=""
432 bold={isActive}
433 onPress={onPress}
434 />
435 )
436}
437HomeMenuItem = React.memo(HomeMenuItem)
438
439let NotificationsMenuItem = ({
440 isActive,
441 onPress,
442}: {
443 isActive: boolean
444 onPress: () => void
445}): React.ReactNode => {
446 const {_} = useLingui()
447 const pal = usePalette('default')
448 const numUnreadNotifications = useUnreadNotifications()
449 return (
450 <MenuItem
451 icon={
452 isActive ? (
453 <BellFilled
454 style={pal.text as StyleProp<ViewStyle>}
455 width={iconWidth}
456 />
457 ) : (
458 <Bell style={pal.text as StyleProp<ViewStyle>} width={iconWidth} />
459 )
460 }
461 label={_(msg`Notifications`)}
462 accessibilityLabel={_(msg`Notifications`)}
463 accessibilityHint={
464 numUnreadNotifications === ''
465 ? ''
466 : _(msg`${numUnreadNotifications} unread`)
467 }
468 count={numUnreadNotifications}
469 bold={isActive}
470 onPress={onPress}
471 />
472 )
473}
474NotificationsMenuItem = React.memo(NotificationsMenuItem)
475
476let FeedsMenuItem = ({
477 isActive,
478 onPress,
479}: {
480 isActive: boolean
481 onPress: () => void
482}): React.ReactNode => {
483 const {_} = useLingui()
484 const pal = usePalette('default')
485 return (
486 <MenuItem
487 icon={
488 isActive ? (
489 <HashtagFilled
490 width={iconWidth}
491 style={pal.text as FontAwesomeIconStyle}
492 />
493 ) : (
494 <Hashtag width={iconWidth} style={pal.text as FontAwesomeIconStyle} />
495 )
496 }
497 label={_(msg`Feeds`)}
498 accessibilityLabel={_(msg`Feeds`)}
499 accessibilityHint=""
500 bold={isActive}
501 onPress={onPress}
502 />
503 )
504}
505FeedsMenuItem = React.memo(FeedsMenuItem)
506
507let ListsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
508 const {_} = useLingui()
509 const pal = usePalette('default')
510 return (
511 <MenuItem
512 icon={<List style={pal.text} width={iconWidth} />}
513 label={_(msg`Lists`)}
514 accessibilityLabel={_(msg`Lists`)}
515 accessibilityHint=""
516 onPress={onPress}
517 />
518 )
519}
520ListsMenuItem = React.memo(ListsMenuItem)
521
522let ProfileMenuItem = ({
523 isActive,
524 onPress,
525}: {
526 isActive: boolean
527 onPress: () => void
528}): React.ReactNode => {
529 const {_} = useLingui()
530 const pal = usePalette('default')
531 return (
532 <MenuItem
533 icon={
534 isActive ? (
535 <UserCircleFilled
536 style={pal.text as StyleProp<ViewStyle>}
537 width={iconWidth}
538 />
539 ) : (
540 <UserCircle
541 style={pal.text as StyleProp<ViewStyle>}
542 width={iconWidth}
543 />
544 )
545 }
546 label={_(msg`Profile`)}
547 accessibilityLabel={_(msg`Profile`)}
548 accessibilityHint=""
549 onPress={onPress}
550 />
551 )
552}
553ProfileMenuItem = React.memo(ProfileMenuItem)
554
555let SettingsMenuItem = ({onPress}: {onPress: () => void}): React.ReactNode => {
556 const {_} = useLingui()
557 const pal = usePalette('default')
558 return (
559 <MenuItem
560 icon={
561 <Settings style={pal.text as StyleProp<ViewStyle>} width={iconWidth} />
562 }
563 label={_(msg`Settings`)}
564 accessibilityLabel={_(msg`Settings`)}
565 accessibilityHint=""
566 onPress={onPress}
567 />
568 )
569}
570SettingsMenuItem = React.memo(SettingsMenuItem)
571
572function MenuItem({
573 icon,
574 label,
575 accessibilityLabel,
576 count,
577 bold,
578 onPress,
579}: MenuItemProps) {
580 const pal = usePalette('default')
581 return (
582 <TouchableOpacity
583 testID={`menuItemButton-${label}`}
584 style={styles.menuItem}
585 onPress={onPress}
586 accessibilityRole="tab"
587 accessibilityLabel={accessibilityLabel}
588 accessibilityHint="">
589 <View style={[styles.menuItemIconWrapper]}>
590 {icon}
591 {count ? (
592 <View
593 style={[
594 styles.menuItemCount,
595 count.length > 2
596 ? styles.menuItemCountHundreds
597 : count.length > 1
598 ? styles.menuItemCountTens
599 : undefined,
600 ]}>
601 <Text style={styles.menuItemCountLabel} numberOfLines={1}>
602 {count}
603 </Text>
604 </View>
605 ) : undefined}
606 </View>
607 <Text
608 type={bold ? '2xl-bold' : '2xl'}
609 style={[pal.text, s.flex1]}
610 numberOfLines={1}>
611 {label}
612 </Text>
613 </TouchableOpacity>
614 )
615}
616
617const styles = StyleSheet.create({
618 view: {
619 flex: 1,
620 paddingBottom: 50,
621 maxWidth: 300,
622 },
623 viewDarkMode: {
624 backgroundColor: '#1B1919',
625 },
626 main: {
627 paddingHorizontal: 20,
628 paddingTop: 20,
629 },
630 smallSpacer: {
631 height: 20,
632 },
633
634 profileCardDisplayName: {
635 marginTop: 20,
636 paddingRight: 30,
637 },
638 profileCardHandle: {
639 marginTop: 4,
640 paddingRight: 30,
641 },
642 profileCardFollowers: {
643 marginTop: 16,
644 },
645
646 menuItem: {
647 flexDirection: 'row',
648 alignItems: 'center',
649 paddingVertical: 16,
650 },
651 menuItemIconWrapper: {
652 width: 24,
653 height: 24,
654 alignItems: 'center',
655 justifyContent: 'center',
656 marginRight: 12,
657 },
658 menuItemCount: {
659 position: 'absolute',
660 width: 'auto',
661 right: -6,
662 top: -4,
663 backgroundColor: colors.blue3,
664 paddingHorizontal: 4,
665 paddingBottom: 1,
666 borderRadius: 6,
667 },
668 menuItemCountTens: {
669 width: 25,
670 },
671 menuItemCountHundreds: {
672 right: -12,
673 width: 34,
674 },
675 menuItemCountLabel: {
676 fontSize: 12,
677 fontWeight: 'bold',
678 fontVariant: ['tabular-nums'],
679 color: colors.white,
680 },
681
682 inviteCodes: {
683 paddingLeft: 0,
684 paddingVertical: 8,
685 flexDirection: 'row',
686 },
687 inviteCodesIcon: {
688 marginRight: 6,
689 flexShrink: 0,
690 marginTop: 2,
691 },
692
693 footer: {
694 flexWrap: 'wrap',
695 flexDirection: 'row',
696 gap: 8,
697 paddingRight: 20,
698 paddingTop: 20,
699 paddingLeft: 20,
700 },
701 footerBtn: {
702 flexDirection: 'row',
703 alignItems: 'center',
704 padding: 10,
705 borderRadius: 25,
706 },
707 footerBtnFeedback: {
708 paddingHorizontal: 20,
709 },
710 footerBtnFeedbackLight: {
711 backgroundColor: '#DDEFFF',
712 },
713 footerBtnFeedbackDark: {
714 backgroundColor: colors.blue6,
715 },
716})