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