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