Bluesky app fork with some witchin' additions 💫

Show tab bar on desktop web (#2998)

* Show tabbar on desktop

* Make bottom border always 1px

* Don't hide/show navbar when switching tabs

* two rows WIP

* Top bar tweaks

* Make scroll adjustement native-only

* Add new web scroll behavior

authored by danabra.mov and committed by

GitHub ac726497 978bcc1b

+134 -88
+2 -68
src/view/com/feeds/FeedPage.tsx
··· 1 1 import React from 'react' 2 - import { 3 - FontAwesomeIcon, 4 - FontAwesomeIconStyle, 5 - } from '@fortawesome/react-native-fontawesome' 6 2 import {useNavigation} from '@react-navigation/native' 7 3 import {useAnalytics} from 'lib/analytics/analytics' 8 4 import {useQueryClient} from '@tanstack/react-query' 9 5 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 10 6 import {MainScrollProvider} from '../util/MainScrollProvider' 11 - import {usePalette} from 'lib/hooks/usePalette' 12 7 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 13 8 import {useSetMinimalShellMode} from '#/state/shell' 14 9 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' 15 10 import {ComposeIcon2} from 'lib/icons' 16 - import {colors, s} from 'lib/styles' 11 + import {s} from 'lib/styles' 17 12 import {View, useWindowDimensions} from 'react-native' 18 13 import {ListMethods} from '../util/List' 19 14 import {Feed} from '../posts/Feed' 20 - import {TextLink} from '../util/Link' 21 15 import {FAB} from '../util/fab/FAB' 22 16 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' 23 17 import {msg} from '@lingui/macro' 24 18 import {useLingui} from '@lingui/react' 25 19 import {useSession} from '#/state/session' 26 20 import {useComposerControls} from '#/state/shell/composer' 27 - import {listenSoftReset, emitSoftReset} from '#/state/events' 21 + import {listenSoftReset} from '#/state/events' 28 22 import {truncateAndInvalidate} from '#/state/queries/util' 29 23 import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers' 30 24 import {isNative} from '#/platform/detection' ··· 47 41 renderEndOfFeed?: () => JSX.Element 48 42 }) { 49 43 const {hasSession} = useSession() 50 - const pal = usePalette('default') 51 44 const {_} = useLingui() 52 45 const navigation = useNavigation() 53 - const {isDesktop} = useWebMediaQueries() 54 46 const queryClient = useQueryClient() 55 47 const {openComposer} = useComposerControls() 56 48 const [isScrolledDown, setIsScrolledDown] = React.useState(false) ··· 99 91 setHasNew(false) 100 92 }, [scrollToTop, feed, queryClient, setHasNew]) 101 93 102 - const ListHeaderComponent = React.useCallback(() => { 103 - if (isDesktop) { 104 - return ( 105 - <View 106 - style={[ 107 - pal.view, 108 - { 109 - flexDirection: 'row', 110 - alignItems: 'center', 111 - justifyContent: 'space-between', 112 - paddingHorizontal: 18, 113 - paddingVertical: 12, 114 - }, 115 - ]}> 116 - <TextLink 117 - type="title-lg" 118 - href="/" 119 - style={[pal.text, {fontWeight: 'bold'}]} 120 - text={ 121 - <> 122 - Bluesky{' '} 123 - {hasNew && ( 124 - <View 125 - style={{ 126 - top: -8, 127 - backgroundColor: colors.blue3, 128 - width: 8, 129 - height: 8, 130 - borderRadius: 4, 131 - }} 132 - /> 133 - )} 134 - </> 135 - } 136 - onPress={emitSoftReset} 137 - /> 138 - {hasSession && ( 139 - <TextLink 140 - type="title-lg" 141 - href="/settings/following-feed" 142 - style={{fontWeight: 'bold'}} 143 - accessibilityLabel={_(msg`Feed Preferences`)} 144 - accessibilityHint="" 145 - text={ 146 - <FontAwesomeIcon 147 - icon="sliders" 148 - style={pal.textLight as FontAwesomeIconStyle} 149 - /> 150 - } 151 - /> 152 - )} 153 - </View> 154 - ) 155 - } 156 - return <></> 157 - }, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession]) 158 - 159 94 return ( 160 95 <View testID={testID} style={s.h100pct}> 161 96 <MainScrollProvider> ··· 171 106 onHasNew={setHasNew} 172 107 renderEmptyState={renderEmptyState} 173 108 renderEndOfFeed={renderEndOfFeed} 174 - ListHeaderComponent={ListHeaderComponent} 175 109 headerOffset={headerOffset} 176 110 /> 177 111 </MainScrollProvider>
-11
src/view/com/home/HomeHeader.tsx
··· 1 1 import React from 'react' 2 2 import {RenderTabBarFnProps} from 'view/com/pager/Pager' 3 3 import {HomeHeaderLayout} from './HomeHeaderLayout' 4 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 5 4 import {usePinnedFeedsInfos} from '#/state/queries/feed' 6 5 import {useNavigation} from '@react-navigation/native' 7 6 import {NavigationProp} from 'lib/routes/types' ··· 10 9 import {usePalette} from '#/lib/hooks/usePalette' 11 10 12 11 export function HomeHeader( 13 - props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 14 - ) { 15 - const {isDesktop} = useWebMediaQueries() 16 - if (isDesktop) { 17 - return null 18 - } 19 - return <HomeHeaderInner {...props} /> 20 - } 21 - 22 - export function HomeHeaderInner( 23 12 props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, 24 13 ) { 25 14 const navigation = useNavigation<NavigationProp>()
+44 -2
src/view/com/home/HomeHeaderLayout.web.tsx
··· 1 1 import React from 'react' 2 - import {StyleSheet} from 'react-native' 2 + import {StyleSheet, View} from 'react-native' 3 3 import Animated from 'react-native-reanimated' 4 4 import {usePalette} from 'lib/hooks/usePalette' 5 5 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 6 6 import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' 7 7 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' 8 8 import {useShellLayout} from '#/state/shell/shell-layout' 9 + import {Logo} from '#/view/icons/Logo' 10 + import {Link, TextLink} from '../util/Link' 11 + import { 12 + FontAwesomeIcon, 13 + FontAwesomeIconStyle, 14 + } from '@fortawesome/react-native-fontawesome' 15 + import {useLingui} from '@lingui/react' 16 + import {msg} from '@lingui/macro' 17 + import {CogIcon} from '#/lib/icons' 9 18 10 19 export function HomeHeaderLayout({children}: {children: React.ReactNode}) { 11 20 const {isMobile} = useWebMediaQueries() ··· 20 29 const pal = usePalette('default') 21 30 const {headerMinimalShellTransform} = useMinimalShellMode() 22 31 const {headerHeight} = useShellLayout() 32 + const {_} = useLingui() 23 33 24 34 return ( 25 35 // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf ··· 28 38 onLayout={e => { 29 39 headerHeight.value = e.nativeEvent.layout.height 30 40 }}> 41 + <View style={[pal.view, styles.topBar]}> 42 + <TextLink 43 + type="title-lg" 44 + href="/settings/following-feed" 45 + accessibilityLabel={_(msg`Following Feed Preferences`)} 46 + accessibilityHint="" 47 + text={ 48 + <FontAwesomeIcon 49 + icon="sliders" 50 + style={pal.textLight as FontAwesomeIconStyle} 51 + /> 52 + } 53 + /> 54 + <Logo width={28} /> 55 + <Link 56 + href="/settings/saved-feeds" 57 + hitSlop={10} 58 + accessibilityRole="button" 59 + accessibilityLabel={_(msg`Edit Saved Feeds`)} 60 + accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}> 61 + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> 62 + </Link> 63 + </View> 31 64 {children} 32 65 </Animated.View> 33 66 ) 34 67 } 35 68 36 69 const styles = StyleSheet.create({ 70 + topBar: { 71 + flexDirection: 'row', 72 + justifyContent: 'space-between', 73 + alignItems: 'center', 74 + paddingHorizontal: 18, 75 + paddingVertical: 8, 76 + marginTop: 8, 77 + width: '100%', 78 + }, 37 79 tabBar: { 38 80 // @ts-ignore Web only 39 81 position: 'sticky', ··· 42 84 left: 'calc(50% - 300px)', 43 85 width: 600, 44 86 top: 0, 45 - flexDirection: 'row', 87 + flexDirection: 'column', 46 88 alignItems: 'center', 47 89 borderLeftWidth: 1, 48 90 borderRightWidth: 1,
-1
src/view/com/home/HomeHeaderLayoutMobile.tsx
··· 103 103 right: 0, 104 104 top: 0, 105 105 flexDirection: 'column', 106 - borderBottomWidth: 1, 107 106 }, 108 107 topBar: { 109 108 flexDirection: 'row',
+73 -5
src/view/com/pager/TabBar.tsx
··· 5 5 import {usePalette} from 'lib/hooks/usePalette' 6 6 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 7 7 import {DraggableScrollView} from './DraggableScrollView' 8 + import {isNative} from '#/platform/detection' 8 9 9 10 export interface TabBarProps { 10 11 testID?: string ··· 14 15 onSelect?: (index: number) => void 15 16 onPressSelected?: (index: number) => void 16 17 } 18 + 19 + // How much of the previous/next item we're showing 20 + // to give the user a hint there's more to scroll. 21 + const OFFSCREEN_ITEM_WIDTH = 20 17 22 18 23 export function TabBar({ 19 24 testID, ··· 25 30 }: TabBarProps) { 26 31 const pal = usePalette('default') 27 32 const scrollElRef = useRef<ScrollView>(null) 33 + const itemRefs = useRef<Array<Element>>([]) 28 34 const [itemXs, setItemXs] = useState<number[]>([]) 29 35 const indicatorStyle = useMemo( 30 36 () => ({borderBottomColor: indicatorColor || pal.colors.link}), ··· 33 39 const {isDesktop, isTablet} = useWebMediaQueries() 34 40 const styles = isDesktop || isTablet ? desktopStyles : mobileStyles 35 41 36 - // scrolls to the selected item when the page changes 37 42 useEffect(() => { 38 - scrollElRef.current?.scrollTo({ 39 - x: 40 - (itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal, 41 - }) 43 + if (isNative) { 44 + // On native, the primary interaction is swiping. 45 + // We adjust the scroll little by little on every tab change. 46 + // Scroll into view but keep the end of the previous item visible. 47 + let x = itemXs[selectedPage] || 0 48 + x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH) 49 + scrollElRef.current?.scrollTo({x}) 50 + } else { 51 + // On the web, the primary interaction is tapping. 52 + // Scrolling under tap feels disorienting so only adjust the scroll offset 53 + // when tapping on an item out of view--and we adjust by almost an entire page. 54 + const parent = scrollElRef?.current?.getScrollableNode?.() 55 + if (!parent) { 56 + return 57 + } 58 + const parentRect = parent.getBoundingClientRect() 59 + if (!parentRect) { 60 + return 61 + } 62 + const { 63 + left: parentLeft, 64 + right: parentRight, 65 + width: parentWidth, 66 + } = parentRect 67 + const child = itemRefs.current[selectedPage] 68 + if (!child) { 69 + return 70 + } 71 + const childRect = child.getBoundingClientRect?.() 72 + if (!childRect) { 73 + return 74 + } 75 + const {left: childLeft, right: childRight, width: childWidth} = childRect 76 + let dx = 0 77 + if (childRight >= parentRight) { 78 + dx += childRight - parentRight 79 + dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH 80 + } else if (childLeft <= parentLeft) { 81 + dx -= parentLeft - childLeft 82 + dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH 83 + } 84 + let x = parent.scrollLeft + dx 85 + x = Math.max(0, x) 86 + x = Math.min(x, parent.scrollWidth - parentWidth) 87 + if (dx !== 0) { 88 + parent.scroll({ 89 + left: x, 90 + behavior: 'smooth', 91 + }) 92 + } 93 + } 42 94 }, [scrollElRef, itemXs, selectedPage, styles]) 43 95 44 96 const onPressItem = useCallback( ··· 78 130 <PressableWithHover 79 131 testID={`${testID}-selector-${i}`} 80 132 key={`${item}-${i}`} 133 + ref={node => (itemRefs.current[i] = node)} 81 134 onLayout={e => onItemLayout(e, i)} 82 135 style={styles.item} 83 136 hoverStyle={pal.viewLight} ··· 94 147 ) 95 148 })} 96 149 </DraggableScrollView> 150 + <View style={[pal.border, styles.outerBottomBorder]} /> 97 151 </View> 98 152 ) 99 153 } ··· 117 171 borderBottomWidth: 3, 118 172 borderBottomColor: 'transparent', 119 173 }, 174 + outerBottomBorder: { 175 + position: 'absolute', 176 + left: 0, 177 + right: 0, 178 + bottom: -1, 179 + borderBottomWidth: 1, 180 + }, 120 181 }) 121 182 122 183 const mobileStyles = StyleSheet.create({ ··· 136 197 paddingBottom: 10, 137 198 borderBottomWidth: 3, 138 199 borderBottomColor: 'transparent', 200 + }, 201 + outerBottomBorder: { 202 + position: 'absolute', 203 + left: 0, 204 + right: 0, 205 + bottom: -1, 206 + borderBottomWidth: 1, 139 207 }, 140 208 })
+15 -1
src/view/com/util/MainScrollProvider.tsx
··· 20 20 const setMode = useSetMinimalShellMode() 21 21 const startDragOffset = useSharedValue<number | null>(null) 22 22 const startMode = useSharedValue<number | null>(null) 23 + const didJustRestoreScroll = useSharedValue<boolean>(false) 23 24 24 25 useEffect(() => { 25 26 if (isWeb) { 26 27 return listenToForcedWindowScroll(() => { 27 28 startDragOffset.value = null 28 29 startMode.value = null 30 + didJustRestoreScroll.value = true 29 31 }) 30 32 } 31 33 }) ··· 86 88 mode.value = newValue 87 89 } 88 90 } else { 91 + if (didJustRestoreScroll.value) { 92 + didJustRestoreScroll.value = false 93 + // Don't hide/show navbar based on scroll restoratoin. 94 + return 95 + } 89 96 // On the web, we don't try to follow the drag because we don't know when it ends. 90 97 // Instead, show/hide immediately based on whether we're scrolling up or down. 91 98 const dy = e.contentOffset.y - (startDragOffset.value ?? 0) ··· 98 105 } 99 106 } 100 107 }, 101 - [headerHeight, mode, setMode, startDragOffset, startMode], 108 + [ 109 + headerHeight, 110 + mode, 111 + setMode, 112 + startDragOffset, 113 + startMode, 114 + didJustRestoreScroll, 115 + ], 102 116 ) 103 117 104 118 return (