forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useRef, useState} from 'react'
2import {Pressable, View} from 'react-native'
3import Animated from 'react-native-reanimated'
4import {useSafeAreaInsets} from 'react-native-safe-area-context'
5import {sanitizeUrl} from '@braintree/sanitize-url'
6import {msg, plural} from '@lingui/core/macro'
7import {useLingui} from '@lingui/react'
8import {Trans} from '@lingui/react/macro'
9import {StackActions, useNavigationState} from '@react-navigation/native'
10
11import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
12import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
13import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
14import {
15 getCurrentRoute,
16 getTabState,
17 isTab,
18 TabState,
19} from '#/lib/routes/helpers'
20import {makeProfileLink} from '#/lib/routes/links'
21import {type CommonNavigatorParams} from '#/lib/routes/types'
22import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers'
23import {emitSoftReset} from '#/state/events'
24import {useModalControls} from '#/state/modals'
25import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
26import {useUnreadNotifications} from '#/state/queries/notifications/unread'
27import {useProfileQuery} from '#/state/queries/profile'
28import {useSession} from '#/state/session'
29import {useLoggedOutViewControls} from '#/state/shell/logged-out'
30import {useShellLayout} from '#/state/shell/shell-layout'
31import {useCloseAllActiveElements} from '#/state/util'
32import {Link} from '#/view/com/util/Link'
33import {UserAvatar} from '#/view/com/util/UserAvatar'
34import {Logo} from '#/view/icons/Logo'
35import {Logotype} from '#/view/icons/Logotype'
36import {atoms as a, useTheme} from '#/alf'
37import {Button, ButtonText} from '#/components/Button'
38import {useDialogControl} from '#/components/Dialog'
39import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
40import {
41 Bell_Filled_Corner0_Rounded as BellFilled,
42 Bell_Stroke2_Corner0_Rounded as Bell,
43} from '#/components/icons/Bell'
44import {
45 HomeOpen_Filled_Corner0_Rounded as HomeFilled,
46 HomeOpen_Stoke2_Corner0_Rounded as Home,
47} from '#/components/icons/HomeOpen'
48import {
49 MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled,
50 MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass,
51} from '#/components/icons/MagnifyingGlass'
52import {
53 Message_Stroke2_Corner0_Rounded as Message,
54 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
55} from '#/components/icons/Message'
56import {Text} from '#/components/Typography'
57import {useAgeAssurance} from '#/ageAssurance'
58import {IS_WEB_TOUCH_DEVICE} from '#/env'
59import {router} from '#/routes'
60import {styles} from './BottomBarStyles'
61
62export function BottomBarWeb() {
63 const {_} = useLingui()
64 const {hasSession, currentAccount} = useSession()
65 const t = useTheme()
66 const {bottom: bottomInset} = useSafeAreaInsets()
67 const footerMinimalShellTransform = useMinimalShellFooterTransform()
68 const {requestSwitchToAccount} = useLoggedOutViewControls()
69 const closeAllActiveElements = useCloseAllActiveElements()
70 const {footerHeight} = useShellLayout()
71 const hideBorder = useHideBottomBarBorder()
72 const accountSwitchControl = useDialogControl()
73 const {data: profile} = useProfileQuery({did: currentAccount?.did})
74 const iconWidth = 26
75
76 const unreadMessageCount = useUnreadMessageCount()
77 const notificationCountStr = useUnreadNotifications()
78 const aa = useAgeAssurance()
79
80 const showSignIn = useCallback(() => {
81 closeAllActiveElements()
82 requestSwitchToAccount({requestedAccount: 'none'})
83 }, [requestSwitchToAccount, closeAllActiveElements])
84
85 const showCreateAccount = useCallback(() => {
86 closeAllActiveElements()
87 requestSwitchToAccount({requestedAccount: 'new'})
88 // setShowLoggedOut(true)
89 }, [requestSwitchToAccount, closeAllActiveElements])
90
91 const onLongPressProfile = useCallback(() => {
92 accountSwitchControl.open()
93 }, [accountSwitchControl])
94
95 return (
96 <>
97 <SwitchAccountDialog control={accountSwitchControl} />
98
99 <Animated.View
100 role="navigation"
101 style={[
102 styles.bottomBar,
103 styles.bottomBarWeb,
104 t.atoms.bg,
105 IS_WEB_TOUCH_DEVICE
106 ? {paddingBottom: Math.max(bottomInset, 15)}
107 : {paddingBottom: bottomInset},
108 hideBorder
109 ? {borderColor: t.atoms.bg.backgroundColor}
110 : t.atoms.border_contrast_low,
111 footerMinimalShellTransform,
112 ]}
113 onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
114 {hasSession ? (
115 <>
116 <NavItem routeName="Home" href="/">
117 {({isActive}) => {
118 const Icon = isActive ? HomeFilled : Home
119 return (
120 <Icon
121 aria-hidden={true}
122 width={iconWidth + 1}
123 style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
124 />
125 )
126 }}
127 </NavItem>
128 <NavItem routeName="Search" href="/search">
129 {({isActive}) => {
130 const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
131 return (
132 <Icon
133 aria-hidden={true}
134 width={iconWidth + 2}
135 style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
136 />
137 )
138 }}
139 </NavItem>
140
141 {hasSession && (
142 <>
143 <NavItem
144 routeName="Messages"
145 href="/messages"
146 notificationCount={
147 aa.flags.chatDisabled
148 ? undefined
149 : unreadMessageCount.numUnread
150 }
151 hasNew={
152 aa.flags.chatDisabled ? false : unreadMessageCount.hasNew
153 }>
154 {({isActive}) => {
155 const Icon = isActive ? MessageFilled : Message
156 return (
157 <Icon
158 aria-hidden={true}
159 width={iconWidth - 1}
160 style={[
161 styles.ctrlIcon,
162 t.atoms.text,
163 styles.messagesIcon,
164 ]}
165 />
166 )
167 }}
168 </NavItem>
169 <NavItem
170 routeName="Notifications"
171 href="/notifications"
172 notificationCount={notificationCountStr}>
173 {({isActive}) => {
174 const Icon = isActive ? BellFilled : Bell
175 return (
176 <Icon
177 aria-hidden={true}
178 width={iconWidth}
179 style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
180 />
181 )
182 }}
183 </NavItem>
184 <NavItem
185 routeName="Profile"
186 href={
187 currentAccount
188 ? makeProfileLink({
189 did: currentAccount.did,
190 handle: currentAccount.handle,
191 })
192 : '/'
193 }
194 onLongPress={onLongPressProfile}>
195 {({isActive}) => (
196 <View style={styles.ctrlIconSizingWrapper}>
197 <View
198 style={[
199 styles.ctrlIcon,
200 styles.profileIcon,
201 isActive && [
202 styles.onProfile,
203 {borderColor: t.atoms.text.color},
204 ],
205 ]}>
206 <UserAvatar
207 avatar={profile?.avatar}
208 size={iconWidth - 3}
209 type={
210 profile?.associated?.labeler ? 'labeler' : 'user'
211 }
212 />
213 </View>
214 </View>
215 )}
216 </NavItem>
217 </>
218 )}
219 </>
220 ) : (
221 <>
222 <View
223 style={[
224 a.w_full,
225 a.flex_row,
226 a.align_center,
227 a.justify_between,
228 a.gap_sm,
229 {
230 paddingTop: 14,
231 paddingBottom: 14,
232 paddingLeft: 14,
233 paddingRight: 6,
234 },
235 ]}>
236 <View style={[a.flex_row, a.align_center, a.gap_md]}>
237 <Logo width={32} />
238 <View style={{paddingTop: 4}}>
239 <Logotype width={80} fill={t.atoms.text.color} />
240 </View>
241 </View>
242
243 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
244 <Button
245 onPress={showCreateAccount}
246 label={_(msg`Create account`)}
247 size="small"
248 variant="solid"
249 color="primary">
250 <ButtonText>
251 <Trans>Create account</Trans>
252 </ButtonText>
253 </Button>
254 <Button
255 onPress={showSignIn}
256 label={_(msg`Sign in`)}
257 size="small"
258 variant="solid"
259 color="secondary">
260 <ButtonText>
261 <Trans>Sign in</Trans>
262 </ButtonText>
263 </Button>
264 </View>
265 </View>
266 </>
267 )}
268 </Animated.View>
269 </>
270 )
271}
272
273const NavItem: React.FC<{
274 children: (props: {isActive: boolean}) => React.ReactNode
275 href: string
276 routeName: string
277 hasNew?: boolean
278 notificationCount?: string
279 onLongPress?: () => void
280}> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => {
281 const t = useTheme()
282 const {_} = useLingui()
283 const {bottom: bottomInset} = useSafeAreaInsets()
284 const {currentAccount} = useSession()
285 const currentRoute = useNavigationState(state => {
286 if (!state) {
287 return {name: 'Home'}
288 }
289 return getCurrentRoute(state)
290 })
291
292 // Checks whether we're on someone else's profile
293 const isOnDifferentProfile =
294 currentRoute.name === 'Profile' &&
295 routeName === 'Profile' &&
296 (currentRoute.params as CommonNavigatorParams['Profile']).name !==
297 currentAccount?.handle
298
299 const isActive =
300 currentRoute.name === 'Profile'
301 ? isTab(currentRoute.name, routeName) &&
302 (currentRoute.params as CommonNavigatorParams['Profile']).name ===
303 (routeName === 'Profile'
304 ? currentAccount?.handle
305 : (currentRoute.params as CommonNavigatorParams['Profile']).name)
306 : isTab(currentRoute.name, routeName)
307
308 if (IS_WEB_TOUCH_DEVICE) {
309 return (
310 <TouchNavItem
311 href={href}
312 routeName={routeName}
313 isActive={isActive}
314 isOnDifferentProfile={isOnDifferentProfile}
315 hasNew={hasNew}
316 notificationCount={notificationCount}
317 onLongPress={onLongPress}>
318 {children}
319 </TouchNavItem>
320 )
321 }
322
323 return (
324 <Link
325 href={href}
326 style={[styles.ctrl, bottomInset === 0 && a.pb_lg]}
327 navigationAction={isOnDifferentProfile ? 'push' : 'navigate'}
328 aria-role="link"
329 aria-label={routeName}
330 accessible={true}
331 onLongPress={onLongPress}>
332 {children({isActive})}
333 {notificationCount ? (
334 <View
335 style={[
336 styles.notificationCount,
337 styles.notificationCountWeb,
338 {backgroundColor: t.palette.primary_500},
339 ]}
340 aria-label={_(
341 msg`${plural(notificationCount, {
342 one: '# unread item',
343 other: '# unread items',
344 })}`,
345 )}>
346 <Text style={styles.notificationCountLabel}>{notificationCount}</Text>
347 </View>
348 ) : hasNew ? (
349 <View
350 style={[styles.hasNewBadge, {backgroundColor: t.palette.primary_500}]}
351 />
352 ) : null}
353 </Link>
354 )
355}
356
357function TouchNavItem({
358 children,
359 href,
360 routeName,
361 isActive,
362 isOnDifferentProfile,
363 hasNew,
364 notificationCount,
365 onLongPress,
366}: {
367 children: (props: {isActive: boolean}) => React.ReactNode
368 href: string
369 routeName: string
370 isActive: boolean
371 isOnDifferentProfile: boolean
372 hasNew?: boolean
373 notificationCount?: string
374 onLongPress?: () => void
375}) {
376 const t = useTheme()
377 const {_} = useLingui()
378 const {bottom: bottomInset} = useSafeAreaInsets()
379 const navigation = useNavigationDeduped()
380 const {closeModal} = useModalControls()
381
382 // CSS transition press animation — runs on compositor thread
383 // so navigation re-renders don't cause jank
384 const [pressed, setPressed] = useState(false)
385 const pressInTime = useRef(0)
386 const pressOutTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
387 undefined,
388 )
389 const ANIM_MS = 100
390
391 const handlePressIn = () => {
392 if (pressOutTimer.current) {
393 clearTimeout(pressOutTimer.current)
394 pressOutTimer.current = undefined
395 }
396 pressInTime.current = Date.now()
397 setPressed(true)
398 }
399
400 const handlePressOut = () => {
401 const elapsed = Date.now() - pressInTime.current
402 const remaining = Math.max(0, ANIM_MS - elapsed)
403 // Wait for scale-down to finish before starting scale-up
404 pressOutTimer.current = setTimeout(() => {
405 setPressed(false)
406 pressOutTimer.current = undefined
407 }, remaining)
408 }
409
410 const onPress = () => {
411 closeModal()
412
413 const sanitizedHref = convertBskyAppUrlIfNeeded(sanitizeUrl(href))
414 const [resolvedRouteName, params] = router.matchPath(sanitizedHref)
415
416 if (isOnDifferentProfile) {
417 // @ts-ignore we're not able to type check on this one -prf
418 navigation.dispatch(StackActions.push(resolvedRouteName, params))
419 } else {
420 const state = navigation.getState()
421 const tabState = getTabState(state, resolvedRouteName)
422 if (tabState === TabState.InsideAtRoot) {
423 emitSoftReset()
424 } else {
425 // @ts-ignore we're not able to type check on this one -prf
426 navigation.navigate(resolvedRouteName, params, {pop: true})
427 }
428 }
429 }
430
431 return (
432 <Pressable
433 style={[
434 styles.ctrl,
435 bottomInset === 0 && a.pb_lg,
436 {
437 transition: `transform ${ANIM_MS}ms`,
438 transform: [{scale: pressed ? 0.8 : 1}],
439 } as any,
440 ]}
441 onPress={onPress}
442 onLongPress={onLongPress}
443 onPressIn={handlePressIn}
444 onPressOut={handlePressOut}
445 unstable_pressDelay={0}
446 accessibilityRole="link"
447 accessibilityLabel={routeName}
448 accessibilityHint="">
449 {children({isActive})}
450 {notificationCount ? (
451 <View
452 style={[
453 styles.notificationCount,
454 styles.notificationCountWeb,
455 {backgroundColor: t.palette.primary_500},
456 ]}
457 aria-label={_(
458 msg`${plural(notificationCount, {
459 one: '# unread item',
460 other: '# unread items',
461 })}`,
462 )}>
463 <Text style={styles.notificationCountLabel}>{notificationCount}</Text>
464 </View>
465 ) : hasNew ? (
466 <View style={styles.hasNewBadge} />
467 ) : null}
468 </Pressable>
469 )
470}