mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useCallback, useMemo, useState} from 'react'
2import {StyleSheet, View} from 'react-native'
3import {type AppBskyActorDefs} from '@atproto/api'
4import {msg, plural, Trans} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6import {
7 useLinkTo,
8 useNavigation,
9 useNavigationState,
10} from '@react-navigation/native'
11
12import {useActorStatus} from '#/lib/actor-status'
13import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
14import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
15import {usePalette} from '#/lib/hooks/usePalette'
16import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
17import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
18import {makeProfileLink} from '#/lib/routes/links'
19import {type CommonNavigatorParams} from '#/lib/routes/types'
20import {useGate} from '#/lib/statsig/statsig'
21import {sanitizeDisplayName} from '#/lib/strings/display-names'
22import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles'
23import {emitSoftReset} from '#/state/events'
24import {useHomeBadge} from '#/state/home-badge'
25import {useFetchHandle} from '#/state/queries/handle'
26import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
27import {useUnreadNotifications} from '#/state/queries/notifications/unread'
28import {useProfilesQuery} from '#/state/queries/profile'
29import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
30import {useLoggedOutViewControls} from '#/state/shell/logged-out'
31import {useCloseAllActiveElements} from '#/state/util'
32import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
33import {PressableWithHover} from '#/view/com/util/PressableWithHover'
34import {UserAvatar} from '#/view/com/util/UserAvatar'
35import {NavSignupCard} from '#/view/shell/NavSignupCard'
36import {atoms as a, tokens, useLayoutBreakpoints, useTheme, web} from '#/alf'
37import {Button, ButtonIcon, ButtonText} from '#/components/Button'
38import {type DialogControlProps} from '#/components/Dialog'
39import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft'
40import {
41 Bell_Filled_Corner0_Rounded as BellFilled,
42 Bell_Stroke2_Corner0_Rounded as Bell,
43} from '#/components/icons/Bell'
44import {
45 BulletList_Filled_Corner0_Rounded as ListFilled,
46 BulletList_Stroke2_Corner0_Rounded as List,
47} from '#/components/icons/BulletList'
48import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
49import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig'
50import {
51 Hashtag_Filled_Corner0_Rounded as HashtagFilled,
52 Hashtag_Stroke2_Corner0_Rounded as Hashtag,
53} from '#/components/icons/Hashtag'
54import {
55 HomeOpen_Filled_Corner0_Rounded as HomeFilled,
56 HomeOpen_Stoke2_Corner0_Rounded as Home,
57} from '#/components/icons/HomeOpen'
58import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
59import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
60import {
61 Message_Stroke2_Corner0_Rounded as Message,
62 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
63} from '#/components/icons/Message'
64import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
65import {
66 SettingsGear2_Filled_Corner0_Rounded as SettingsFilled,
67 SettingsGear2_Stroke2_Corner0_Rounded as Settings,
68} from '#/components/icons/SettingsGear2'
69import {
70 UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
71 UserCircle_Stroke2_Corner0_Rounded as UserCircle,
72} from '#/components/icons/UserCircle'
73import {CENTER_COLUMN_OFFSET} from '#/components/Layout'
74import * as Menu from '#/components/Menu'
75import * as Prompt from '#/components/Prompt'
76import {Text} from '#/components/Typography'
77import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
78import {router} from '../../../routes'
79
80const NAV_ICON_WIDTH = 28
81
82function ProfileCard() {
83 const {currentAccount, accounts} = useSession()
84 const {logoutEveryAccount} = useSessionApi()
85 const {isLoading, data} = useProfilesQuery({
86 handles: accounts.map(acc => acc.did),
87 })
88 const profiles = data?.profiles
89 const signOutPromptControl = Prompt.usePromptControl()
90 const {leftNavMinimal} = useLayoutBreakpoints()
91 const {_} = useLingui()
92 const t = useTheme()
93
94 const size = 48
95
96 const profile = profiles?.find(p => p.did === currentAccount!.did)
97 const otherAccounts = accounts
98 .filter(acc => acc.did !== currentAccount!.did)
99 .map(account => ({
100 account,
101 profile: profiles?.find(p => p.did === account.did),
102 }))
103
104 const {isActive: live} = useActorStatus(profile)
105
106 return (
107 <View style={[a.my_md, !leftNavMinimal && [a.w_full, a.align_start]]}>
108 {!isLoading && profile ? (
109 <Menu.Root>
110 <Menu.Trigger label={_(msg`Switch accounts`)}>
111 {({props, state, control}) => {
112 const active = state.hovered || state.focused || control.isOpen
113 return (
114 <Button
115 label={props.accessibilityLabel}
116 {...props}
117 style={[
118 a.w_full,
119 a.transition_color,
120 active ? t.atoms.bg_contrast_25 : a.transition_delay_50ms,
121 a.rounded_full,
122 a.justify_between,
123 a.align_center,
124 a.flex_row,
125 {gap: 6},
126 !leftNavMinimal && [a.pl_lg, a.pr_md],
127 ]}>
128 <View
129 style={[
130 !PlatformInfo.getIsReducedMotionEnabled() && [
131 a.transition_transform,
132 {transitionDuration: '250ms'},
133 !active && a.transition_delay_50ms,
134 ],
135 a.relative,
136 a.z_10,
137 active && {
138 transform: [
139 {scale: !leftNavMinimal ? 2 / 3 : 0.8},
140 {translateX: !leftNavMinimal ? -22 : 0},
141 ],
142 },
143 ]}>
144 <UserAvatar
145 avatar={profile.avatar}
146 size={size}
147 type={profile?.associated?.labeler ? 'labeler' : 'user'}
148 live={live}
149 />
150 </View>
151 {!leftNavMinimal && (
152 <>
153 <View
154 style={[
155 a.flex_1,
156 a.transition_opacity,
157 !active && a.transition_delay_50ms,
158 {
159 marginLeft: tokens.space.xl * -1,
160 opacity: active ? 1 : 0,
161 },
162 ]}>
163 <Text
164 style={[a.font_heavy, a.text_sm, a.leading_snug]}
165 numberOfLines={1}>
166 {sanitizeDisplayName(
167 profile.displayName || profile.handle,
168 )}
169 </Text>
170 <Text
171 style={[
172 a.text_xs,
173 a.leading_snug,
174 t.atoms.text_contrast_medium,
175 ]}
176 numberOfLines={1}>
177 {sanitizeHandle(profile.handle, '@')}
178 </Text>
179 </View>
180 <EllipsisIcon
181 aria-hidden={true}
182 style={[
183 t.atoms.text_contrast_medium,
184 a.transition_opacity,
185 {opacity: active ? 1 : 0},
186 ]}
187 size="sm"
188 />
189 </>
190 )}
191 </Button>
192 )
193 }}
194 </Menu.Trigger>
195 <SwitchMenuItems
196 accounts={otherAccounts}
197 signOutPromptControl={signOutPromptControl}
198 />
199 </Menu.Root>
200 ) : (
201 <LoadingPlaceholder
202 width={size}
203 height={size}
204 style={[{borderRadius: size}, !leftNavMinimal && a.ml_lg]}
205 />
206 )}
207 <Prompt.Basic
208 control={signOutPromptControl}
209 title={_(msg`Sign out?`)}
210 description={_(msg`You will be signed out of all your accounts.`)}
211 onConfirm={() => logoutEveryAccount('Settings')}
212 confirmButtonCta={_(msg`Sign out`)}
213 cancelButtonCta={_(msg`Cancel`)}
214 confirmButtonColor="negative"
215 />
216 </View>
217 )
218}
219
220function SwitchMenuItems({
221 accounts,
222 signOutPromptControl,
223}: {
224 accounts:
225 | {
226 account: SessionAccount
227 profile?: AppBskyActorDefs.ProfileViewDetailed
228 }[]
229 | undefined
230 signOutPromptControl: DialogControlProps
231}) {
232 const {_} = useLingui()
233 const {setShowLoggedOut} = useLoggedOutViewControls()
234 const closeEverything = useCloseAllActiveElements()
235
236 const onAddAnotherAccount = () => {
237 setShowLoggedOut(true)
238 closeEverything()
239 }
240 return (
241 <Menu.Outer>
242 {accounts && accounts.length > 0 && (
243 <>
244 <Menu.Group>
245 <Menu.LabelText>
246 <Trans>Switch account</Trans>
247 </Menu.LabelText>
248 {accounts.map(other => (
249 <SwitchMenuItem
250 key={other.account.did}
251 account={other.account}
252 profile={other.profile}
253 />
254 ))}
255 </Menu.Group>
256 <Menu.Divider />
257 </>
258 )}
259 <Menu.Item
260 label={_(msg`Add another account`)}
261 onPress={onAddAnotherAccount}>
262 <Menu.ItemIcon icon={PlusIcon} />
263 <Menu.ItemText>
264 <Trans>Add another account</Trans>
265 </Menu.ItemText>
266 </Menu.Item>
267 <Menu.Item label={_(msg`Sign out`)} onPress={signOutPromptControl.open}>
268 <Menu.ItemIcon icon={LeaveIcon} />
269 <Menu.ItemText>
270 <Trans>Sign out</Trans>
271 </Menu.ItemText>
272 </Menu.Item>
273 </Menu.Outer>
274 )
275}
276
277function SwitchMenuItem({
278 account,
279 profile,
280}: {
281 account: SessionAccount
282 profile: AppBskyActorDefs.ProfileViewDetailed | undefined
283}) {
284 const {_} = useLingui()
285 const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
286 const {isActive: live} = useActorStatus(profile)
287
288 return (
289 <Menu.Item
290 disabled={!!pendingDid}
291 style={[a.gap_sm, {minWidth: 150}]}
292 key={account.did}
293 label={_(
294 msg`Switch to ${sanitizeHandle(
295 profile?.handle ?? account.handle,
296 '@',
297 )}`,
298 )}
299 onPress={() => onPressSwitchAccount(account, 'SwitchAccount')}>
300 <View>
301 <UserAvatar
302 avatar={profile?.avatar}
303 size={20}
304 type={profile?.associated?.labeler ? 'labeler' : 'user'}
305 live={live}
306 hideLiveBadge
307 />
308 </View>
309 <Menu.ItemText>
310 {sanitizeHandle(profile?.handle ?? account.handle, '@')}
311 </Menu.ItemText>
312 </Menu.Item>
313 )
314}
315
316interface NavItemProps {
317 count?: string
318 hasNew?: boolean
319 href: string
320 icon: JSX.Element
321 iconFilled: JSX.Element
322 label: string
323}
324function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
325 const t = useTheme()
326 const {_} = useLingui()
327 const {currentAccount} = useSession()
328 const {leftNavMinimal} = useLayoutBreakpoints()
329 const [pathName] = useMemo(() => router.matchPath(href), [href])
330 const currentRouteInfo = useNavigationState(state => {
331 if (!state) {
332 return {name: 'Home'}
333 }
334 return getCurrentRoute(state)
335 })
336 let isCurrent =
337 currentRouteInfo.name === 'Profile'
338 ? isTab(currentRouteInfo.name, pathName) &&
339 (currentRouteInfo.params as CommonNavigatorParams['Profile']).name ===
340 currentAccount?.handle
341 : isTab(currentRouteInfo.name, pathName)
342 const linkTo = useLinkTo()
343 const onPressWrapped = useCallback(
344 (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
345 if (e.ctrlKey || e.metaKey || e.altKey) {
346 return
347 }
348 e.preventDefault()
349 if (isCurrent) {
350 emitSoftReset()
351 } else {
352 linkTo(href)
353 }
354 },
355 [linkTo, href, isCurrent],
356 )
357
358 return (
359 <PressableWithHover
360 style={[
361 a.flex_row,
362 a.align_center,
363 a.p_md,
364 a.rounded_sm,
365 a.gap_sm,
366 a.outline_inset_1,
367 a.transition_color,
368 ]}
369 hoverStyle={t.atoms.bg_contrast_25}
370 // @ts-expect-error the function signature differs on web -prf
371 onPress={onPressWrapped}
372 href={href}
373 dataSet={{noUnderline: 1}}
374 role="link"
375 accessibilityLabel={label}
376 accessibilityHint="">
377 <View
378 style={[
379 a.align_center,
380 a.justify_center,
381 a.z_10,
382 {
383 width: 24,
384 height: 24,
385 },
386 leftNavMinimal && {
387 width: 40,
388 height: 40,
389 },
390 ]}>
391 {isCurrent ? iconFilled : icon}
392 {typeof count === 'string' && count ? (
393 <View
394 style={[
395 a.absolute,
396 a.inset_0,
397 {right: -20}, // more breathing room
398 ]}>
399 <Text
400 accessibilityLabel={_(
401 msg`${plural(count, {
402 one: '# unread item',
403 other: '# unread items',
404 })}`,
405 )}
406 accessibilityHint=""
407 accessible={true}
408 numberOfLines={1}
409 style={[
410 a.absolute,
411 a.text_xs,
412 a.font_bold,
413 a.rounded_full,
414 a.text_center,
415 a.leading_tight,
416 {
417 top: '-10%',
418 left: count.length === 1 ? 12 : 8,
419 backgroundColor: t.palette.primary_500,
420 color: t.palette.white,
421 lineHeight: a.text_sm.fontSize,
422 paddingHorizontal: 4,
423 paddingVertical: 1,
424 minWidth: 16,
425 },
426 leftNavMinimal && [
427 {
428 top: '10%',
429 left: count.length === 1 ? 20 : 16,
430 },
431 ],
432 ]}>
433 {count}
434 </Text>
435 </View>
436 ) : hasNew ? (
437 <View
438 style={[
439 a.absolute,
440 a.rounded_full,
441 {
442 backgroundColor: t.palette.primary_500,
443 width: 8,
444 height: 8,
445 right: -2,
446 top: -4,
447 },
448 leftNavMinimal && {
449 right: 4,
450 top: 2,
451 },
452 ]}
453 />
454 ) : null}
455 </View>
456 {!leftNavMinimal && (
457 <Text style={[a.text_xl, isCurrent ? a.font_heavy : a.font_normal]}>
458 {label}
459 </Text>
460 )}
461 </PressableWithHover>
462 )
463}
464
465function ComposeBtn() {
466 const {currentAccount} = useSession()
467 const {getState} = useNavigation()
468 const {openComposer} = useOpenComposer()
469 const {_} = useLingui()
470 const {leftNavMinimal} = useLayoutBreakpoints()
471 const [isFetchingHandle, setIsFetchingHandle] = useState(false)
472 const fetchHandle = useFetchHandle()
473
474 const getProfileHandle = async () => {
475 const routes = getState()?.routes
476 const currentRoute = routes?.[routes?.length - 1]
477
478 if (currentRoute?.name === 'Profile') {
479 let handle: string | undefined = (
480 currentRoute.params as CommonNavigatorParams['Profile']
481 ).name
482
483 if (handle.startsWith('did:')) {
484 try {
485 setIsFetchingHandle(true)
486 handle = await fetchHandle(handle)
487 } catch (e) {
488 handle = undefined
489 } finally {
490 setIsFetchingHandle(false)
491 }
492 }
493
494 if (
495 !handle ||
496 handle === currentAccount?.handle ||
497 isInvalidHandle(handle)
498 )
499 return undefined
500
501 return handle
502 }
503
504 return undefined
505 }
506
507 const onPressCompose = async () =>
508 openComposer({mention: await getProfileHandle()})
509
510 if (leftNavMinimal) {
511 return null
512 }
513
514 return (
515 <View style={[a.flex_row, a.pl_md, a.pt_xl]}>
516 <Button
517 disabled={isFetchingHandle}
518 label={_(msg`Compose new post`)}
519 onPress={onPressCompose}
520 size="large"
521 variant="solid"
522 color="primary"
523 style={[a.rounded_full]}>
524 <ButtonIcon icon={EditBig} position="left" />
525 <ButtonText>
526 <Trans context="action">New Post</Trans>
527 </ButtonText>
528 </Button>
529 </View>
530 )
531}
532
533function ChatNavItem() {
534 const pal = usePalette('default')
535 const {_} = useLingui()
536 const numUnreadMessages = useUnreadMessageCount()
537
538 return (
539 <NavItem
540 href="/messages"
541 count={numUnreadMessages.numUnread}
542 hasNew={numUnreadMessages.hasNew}
543 icon={
544 <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} />
545 }
546 iconFilled={
547 <MessageFilled
548 style={pal.text}
549 aria-hidden={true}
550 width={NAV_ICON_WIDTH}
551 />
552 }
553 label={_(msg`Chat`)}
554 />
555 )
556}
557
558export function DesktopLeftNav() {
559 const {hasSession, currentAccount} = useSession()
560 const pal = usePalette('default')
561 const {_} = useLingui()
562 const {isDesktop} = useWebMediaQueries()
563 const {leftNavMinimal, centerColumnOffset} = useLayoutBreakpoints()
564 const numUnreadNotifications = useUnreadNotifications()
565 const hasHomeBadge = useHomeBadge()
566 const gate = useGate()
567
568 if (!hasSession && !isDesktop) {
569 return null
570 }
571
572 return (
573 <View
574 role="navigation"
575 style={[
576 a.px_xl,
577 styles.leftNav,
578 leftNavMinimal && styles.leftNavMinimal,
579 {
580 transform: [
581 {
582 translateX:
583 -300 + (centerColumnOffset ? CENTER_COLUMN_OFFSET : 0),
584 },
585 {translateX: '-100%'},
586 ...a.scrollbar_offset.transform,
587 ],
588 },
589 ]}>
590 {hasSession ? (
591 <ProfileCard />
592 ) : !leftNavMinimal ? (
593 <View style={[a.pt_xl]}>
594 <NavSignupCard />
595 </View>
596 ) : null}
597
598 {hasSession && (
599 <>
600 <NavItem
601 href="/"
602 hasNew={hasHomeBadge && gate('remove_show_latest_button')}
603 icon={
604 <Home
605 aria-hidden={true}
606 width={NAV_ICON_WIDTH}
607 style={pal.text}
608 />
609 }
610 iconFilled={
611 <HomeFilled
612 aria-hidden={true}
613 width={NAV_ICON_WIDTH}
614 style={pal.text}
615 />
616 }
617 label={_(msg`Home`)}
618 />
619 <NavItem
620 href="/search"
621 icon={
622 <MagnifyingGlass
623 style={pal.text}
624 aria-hidden={true}
625 width={NAV_ICON_WIDTH}
626 />
627 }
628 iconFilled={
629 <MagnifyingGlassFilled
630 style={pal.text}
631 aria-hidden={true}
632 width={NAV_ICON_WIDTH}
633 />
634 }
635 label={_(msg`Explore`)}
636 />
637 <NavItem
638 href="/notifications"
639 count={numUnreadNotifications}
640 icon={
641 <Bell
642 aria-hidden={true}
643 width={NAV_ICON_WIDTH}
644 style={pal.text}
645 />
646 }
647 iconFilled={
648 <BellFilled
649 aria-hidden={true}
650 width={NAV_ICON_WIDTH}
651 style={pal.text}
652 />
653 }
654 label={_(msg`Notifications`)}
655 />
656 <ChatNavItem />
657 <NavItem
658 href="/feeds"
659 icon={
660 <Hashtag
661 style={pal.text}
662 aria-hidden={true}
663 width={NAV_ICON_WIDTH}
664 />
665 }
666 iconFilled={
667 <HashtagFilled
668 style={pal.text}
669 aria-hidden={true}
670 width={NAV_ICON_WIDTH}
671 />
672 }
673 label={_(msg`Feeds`)}
674 />
675 <NavItem
676 href="/lists"
677 icon={
678 <List
679 style={pal.text}
680 aria-hidden={true}
681 width={NAV_ICON_WIDTH}
682 />
683 }
684 iconFilled={
685 <ListFilled
686 style={pal.text}
687 aria-hidden={true}
688 width={NAV_ICON_WIDTH}
689 />
690 }
691 label={_(msg`Lists`)}
692 />
693 <NavItem
694 href={currentAccount ? makeProfileLink(currentAccount) : '/'}
695 icon={
696 <UserCircle
697 aria-hidden={true}
698 width={NAV_ICON_WIDTH}
699 style={pal.text}
700 />
701 }
702 iconFilled={
703 <UserCircleFilled
704 aria-hidden={true}
705 width={NAV_ICON_WIDTH}
706 style={pal.text}
707 />
708 }
709 label={_(msg`Profile`)}
710 />
711 <NavItem
712 href="/settings"
713 icon={
714 <Settings
715 aria-hidden={true}
716 width={NAV_ICON_WIDTH}
717 style={pal.text}
718 />
719 }
720 iconFilled={
721 <SettingsFilled
722 aria-hidden={true}
723 width={NAV_ICON_WIDTH}
724 style={pal.text}
725 />
726 }
727 label={_(msg`Settings`)}
728 />
729
730 <ComposeBtn />
731 </>
732 )}
733 </View>
734 )
735}
736
737const styles = StyleSheet.create({
738 leftNav: {
739 ...a.fixed,
740 top: 0,
741 paddingTop: 10,
742 paddingBottom: 10,
743 left: '50%',
744 width: 240,
745 // @ts-expect-error web only
746 maxHeight: '100vh',
747 overflowY: 'auto',
748 },
749 leftNavMinimal: {
750 paddingTop: 0,
751 paddingBottom: 0,
752 paddingLeft: 0,
753 paddingRight: 0,
754 height: '100%',
755 width: 86,
756 alignItems: 'center',
757 ...web({overflowX: 'hidden'}),
758 },
759 backBtn: {
760 position: 'absolute',
761 top: 12,
762 right: 12,
763 width: 30,
764 height: 30,
765 },
766})