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