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