Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {type JSX, useCallback} from 'react'
2import {type GestureResponderEvent, View} from 'react-native'
3import Animated from 'react-native-reanimated'
4import {useSafeAreaInsets} from 'react-native-safe-area-context'
5import {msg, plural} from '@lingui/core/macro'
6import {useLingui} from '@lingui/react'
7import {Trans} from '@lingui/react/macro'
8import {type BottomTabBarProps} from '@react-navigation/bottom-tabs'
9import {StackActions} from '@react-navigation/native'
10
11import {PressableScale} from '#/lib/custom-animations/PressableScale'
12import {BOTTOM_BAR_AVI} from '#/lib/demo'
13import {useHaptics} from '#/lib/haptics'
14import {useDedupe} from '#/lib/hooks/useDedupe'
15import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
16import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
17import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
18import {clamp} from '#/lib/numbers'
19import {getTabState, TabState} from '#/lib/routes/helpers'
20import {emitSoftReset} from '#/state/events'
21import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
22import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
23import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations'
24import {useUnreadNotifications} from '#/state/queries/notifications/unread'
25import {useProfileQuery} from '#/state/queries/profile'
26import {useSession} from '#/state/session'
27import {useLoggedOutViewControls} from '#/state/shell/logged-out'
28import {useShellLayout} from '#/state/shell/shell-layout'
29import {useCloseAllActiveElements} from '#/state/util'
30import {UserAvatar} from '#/view/com/util/UserAvatar'
31import {Logo} from '#/view/icons/Logo'
32import {Logotype} from '#/view/icons/Logotype'
33import {atoms as a, useTheme} from '#/alf'
34import {Button, ButtonText} from '#/components/Button'
35import {useDialogControl} from '#/components/Dialog'
36import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
37import {
38 Bell_Filled_Corner0_Rounded as BellFilled,
39 Bell_Stroke2_Corner0_Rounded as Bell,
40} from '#/components/icons/Bell'
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 {Text} from '#/components/Typography'
54import {useActorStatus} from '#/features/liveNow'
55import {useDemoMode} from '#/storage/hooks/demo-mode'
56import {styles} from './BottomBarStyles'
57
58type TabOptions = 'Home' | 'Search' | 'Messages' | 'Notifications' | 'MyProfile'
59
60export function BottomBar({navigation}: BottomTabBarProps) {
61 const {hasSession, currentAccount} = useSession()
62 const t = useTheme()
63 const {_} = useLingui()
64 const safeAreaInsets = useSafeAreaInsets()
65 const {footerHeight} = useShellLayout()
66 const {isAtHome, isAtSearch, isAtNotifications, isAtMyProfile, isAtMessages} =
67 useNavigationTabState()
68 const numUnreadNotifications = useUnreadNotifications()
69 const numUnreadMessages = useUnreadMessageCount()
70 const footerMinimalShellTransform = useMinimalShellFooterTransform()
71 const {data: profile} = useProfileQuery({did: currentAccount?.did})
72 const {requestSwitchToAccount} = useLoggedOutViewControls()
73 const closeAllActiveElements = useCloseAllActiveElements()
74 const dedupe = useDedupe()
75 const accountSwitchControl = useDialogControl()
76 const playHaptic = useHaptics()
77 const hideBorder = useHideBottomBarBorder()
78 const iconWidth = 28
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 onPressTab = useCallback(
92 (tab: TabOptions) => {
93 const state = navigation.getState()
94 const tabState = getTabState(state, tab)
95 if (tabState === TabState.InsideAtRoot) {
96 emitSoftReset()
97 } else if (tabState === TabState.Inside) {
98 // find the correct navigator in which to pop-to-top
99 const target = state.routes.find(route => route.name === `${tab}Tab`)
100 ?.state?.key
101 dedupe(() => {
102 if (target) {
103 // if we found it, trigger pop-to-top
104 navigation.dispatch({
105 ...StackActions.popToTop(),
106 target,
107 })
108 } else {
109 // fallback: reset navigation
110 navigation.reset({
111 index: 0,
112 routes: [{name: `${tab}Tab`}],
113 })
114 }
115 })
116 } else {
117 dedupe(() => navigation.navigate(`${tab}Tab`))
118 }
119 },
120 [navigation, dedupe],
121 )
122 const onPressHome = useCallback(() => onPressTab('Home'), [onPressTab])
123 const onPressSearch = useCallback(() => onPressTab('Search'), [onPressTab])
124 const onPressNotifications = useCallback(
125 () => onPressTab('Notifications'),
126 [onPressTab],
127 )
128 const onPressProfile = useCallback(() => {
129 onPressTab('MyProfile')
130 }, [onPressTab])
131 const onPressMessages = useCallback(() => {
132 onPressTab('Messages')
133 }, [onPressTab])
134
135 const onLongPressProfile = useCallback(() => {
136 playHaptic()
137 accountSwitchControl.open()
138 }, [accountSwitchControl, playHaptic])
139
140 const [demoMode] = useDemoMode()
141 const {isActive: live} = useActorStatus(profile)
142
143 const enableSquareAvatars = useEnableSquareAvatars()
144
145 return (
146 <>
147 <SwitchAccountDialog control={accountSwitchControl} />
148
149 <Animated.View
150 style={[
151 styles.bottomBar,
152 t.atoms.bg,
153 hideBorder
154 ? {borderColor: t.atoms.bg.backgroundColor}
155 : t.atoms.border_contrast_low,
156 {paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
157 footerMinimalShellTransform,
158 ]}
159 onLayout={e => {
160 footerHeight.set(e.nativeEvent.layout.height)
161 }}>
162 {hasSession ? (
163 <>
164 <Btn
165 testID="bottomBarHomeBtn"
166 icon={
167 isAtHome ? (
168 <HomeFilled
169 width={iconWidth + 1}
170 style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
171 />
172 ) : (
173 <Home
174 width={iconWidth + 1}
175 style={[styles.ctrlIcon, t.atoms.text, styles.homeIcon]}
176 />
177 )
178 }
179 onPress={onPressHome}
180 accessibilityRole="tab"
181 accessibilityLabel={_(msg`Home`)}
182 accessibilityHint=""
183 />
184 <Btn
185 icon={
186 isAtSearch ? (
187 <MagnifyingGlassFilled
188 width={iconWidth + 2}
189 style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
190 />
191 ) : (
192 <MagnifyingGlass
193 testID="bottomBarSearchBtn"
194 width={iconWidth + 2}
195 style={[styles.ctrlIcon, t.atoms.text, styles.searchIcon]}
196 />
197 )
198 }
199 onPress={onPressSearch}
200 accessibilityRole="search"
201 accessibilityLabel={_(msg`Search`)}
202 accessibilityHint=""
203 />
204 <Btn
205 testID="bottomBarMessagesBtn"
206 icon={
207 isAtMessages ? (
208 <MessageFilled
209 width={iconWidth - 1}
210 style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
211 />
212 ) : (
213 <Message
214 width={iconWidth - 1}
215 style={[styles.ctrlIcon, t.atoms.text, styles.feedsIcon]}
216 />
217 )
218 }
219 onPress={onPressMessages}
220 notificationCount={numUnreadMessages.numUnread}
221 hasNew={numUnreadMessages.hasNew}
222 accessible={true}
223 accessibilityRole="tab"
224 accessibilityLabel={_(msg`Chat`)}
225 accessibilityHint={
226 numUnreadMessages.count > 0
227 ? _(
228 plural(numUnreadMessages.numUnread ?? 0, {
229 one: '# unread item',
230 other: '# unread items',
231 }),
232 )
233 : ''
234 }
235 />
236 <Btn
237 testID="bottomBarNotificationsBtn"
238 icon={
239 isAtNotifications ? (
240 <BellFilled
241 width={iconWidth}
242 style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
243 />
244 ) : (
245 <Bell
246 width={iconWidth}
247 style={[styles.ctrlIcon, t.atoms.text, styles.bellIcon]}
248 />
249 )
250 }
251 onPress={onPressNotifications}
252 notificationCount={numUnreadNotifications}
253 accessible={true}
254 accessibilityRole="tab"
255 accessibilityLabel={_(msg`Notifications`)}
256 accessibilityHint={
257 numUnreadNotifications === ''
258 ? ''
259 : _(
260 plural(numUnreadNotifications ?? 0, {
261 one: '# unread item',
262 other: '# unread items',
263 }),
264 )
265 }
266 />
267 <Btn
268 testID="bottomBarProfileBtn"
269 icon={
270 <View style={styles.ctrlIconSizingWrapper}>
271 <View
272 style={[
273 styles.ctrlIcon,
274 styles.profileIcon,
275 isAtMyProfile && [
276 enableSquareAvatars
277 ? styles.onProfileSquare
278 : styles.onProfile,
279 {
280 borderColor: t.atoms.text.color,
281 borderWidth: live ? 0 : enableSquareAvatars ? 1.5 : 1,
282 },
283 ],
284 ]}>
285 <UserAvatar
286 avatar={demoMode ? BOTTOM_BAR_AVI : profile?.avatar}
287 size={iconWidth - (isAtMyProfile ? 3 : 2)}
288 // See https://github.com/bluesky-social/social-app/pull/1801:
289 usePlainRNImage={true}
290 type={profile?.associated?.labeler ? 'labeler' : 'user'}
291 live={live}
292 hideLiveBadge
293 />
294 </View>
295 </View>
296 }
297 onPress={onPressProfile}
298 onLongPress={onLongPressProfile}
299 accessibilityRole="tab"
300 accessibilityLabel={_(msg`Profile`)}
301 accessibilityHint=""
302 />
303 </>
304 ) : (
305 <>
306 <View
307 style={{
308 width: '100%',
309 flexDirection: 'row',
310 alignItems: 'center',
311 justifyContent: 'space-between',
312 paddingTop: 14,
313 paddingBottom: 2,
314 paddingLeft: 14,
315 paddingRight: 6,
316 gap: 8,
317 }}>
318 <View
319 style={{flexDirection: 'row', alignItems: 'center', gap: 8}}>
320 <Logo width={28} />
321 <View style={{paddingTop: 4}}>
322 <Logotype width={80} fill={t.atoms.text.color} />
323 </View>
324 </View>
325
326 <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
327 <Button
328 onPress={showCreateAccount}
329 label={_(msg`Create account`)}
330 size="small"
331 variant="solid"
332 color="primary">
333 <ButtonText>
334 <Trans>Create account</Trans>
335 </ButtonText>
336 </Button>
337 <Button
338 onPress={showSignIn}
339 label={_(msg`Sign in`)}
340 size="small"
341 variant="solid"
342 color="secondary">
343 <ButtonText>
344 <Trans>Sign in</Trans>
345 </ButtonText>
346 </Button>
347 </View>
348 </View>
349 </>
350 )}
351 </Animated.View>
352 </>
353 )
354}
355
356interface BtnProps
357 extends Pick<
358 React.ComponentProps<typeof PressableScale>,
359 | 'accessible'
360 | 'accessibilityRole'
361 | 'accessibilityHint'
362 | 'accessibilityLabel'
363 > {
364 testID?: string
365 icon: JSX.Element
366 notificationCount?: string
367 hasNew?: boolean
368 onPress?: (event: GestureResponderEvent) => void
369 onLongPress?: (event: GestureResponderEvent) => void
370}
371
372function Btn({
373 testID,
374 icon,
375 hasNew,
376 notificationCount,
377 onPress,
378 onLongPress,
379 accessible,
380 accessibilityHint,
381 accessibilityLabel,
382}: BtnProps) {
383 const enableSquareButtons = useEnableSquareButtons()
384 const t = useTheme()
385
386 return (
387 <PressableScale
388 testID={testID}
389 style={[styles.ctrl, a.flex_1]}
390 onPress={onPress}
391 onLongPress={onLongPress}
392 accessible={accessible}
393 accessibilityLabel={accessibilityLabel}
394 accessibilityHint={accessibilityHint}
395 targetScale={0.8}
396 accessibilityLargeContentTitle={accessibilityLabel}
397 accessibilityShowsLargeContentViewer>
398 {icon}
399 {notificationCount ? (
400 <View
401 style={[
402 styles.notificationCount,
403 enableSquareButtons ? a.rounded_sm : a.rounded_full,
404 {backgroundColor: t.palette.primary_500},
405 ]}>
406 <Text style={styles.notificationCountLabel}>{notificationCount}</Text>
407 </View>
408 ) : hasNew ? (
409 <View
410 style={[
411 styles.hasNewBadge,
412 enableSquareButtons ? a.rounded_sm : a.rounded_full,
413 ]}
414 />
415 ) : null}
416 </PressableScale>
417 )
418}