Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

New Web Layout (#2126)

* Rip out virtualization on the web

* Screw around with layout

* onEndReached

* scrollToOffset

* Fix background

* onScroll

* Shell bars

* More scroll

* Fixes

* position: sticky

* Clean up 1

* Clean up 2

* Undo PagerWithHeader changes and fork it

* Trim down both versions

* Cleanup 3

* Memoize, lint

* Don't scroll away modal or lightbox

* Add content-visibility for rows

* Fix composer

* Fix types

* Fix borked scroll animation

* Fixes to layout

* More FlatList parity

* Layout fixes

* Fix more layout

* More layout

* More layouts

* Fix profile layout

* Remove onScroll

* Display: none inactive pages

* Add an intermediate List component

* Fix type

* Add onScrolledDownChange

* Port pager to use onScrolledDownChange

* Fix on mobile

* Don't pass down onScroll (replacement TBD)

* Remove resetMainScroll

* Replace onMainScroll with MainScrollProvider

* Hook ScrollProvider to pager

* Fix the remaining special case

* Optimize a bit

* Enforce that onScroll cannot be passed

* Keep value updated even if no handler

* Also memo it

* Move the fork to List.web

* Add scroll handler

* Consolidate List props a bit

* More stuff

* Rm unused

* Simplify

* Make isScrolledDown work

* Oops

* Fixes

* Hook up context scroll handlers

* Scroll restore for tabs

* Route scroll restoration POC

* Fix some issues with restoration

* Remove bad idea

* Fix pager scroll restoration

* Undo accidental locale changes

* onContentSizeChange

* Scroll to post

* Better positioning

* Layout fixes

* Factor out navigation stuff

* Cleanup

* Oops

* Cleanup

* Fixes and types

* Naming etc

* Fix crash

* Match FL semantics

* Snap the header scroll on the web

* Add body scroll lock

* Scroll to top on search

* Fix types

* Typos

* Fix Safari overflow

* Fix search positioning

* Add border

* Patch react navigation

* Revert "Patch react navigation"

This reverts commit 62516ed9c20410d166e1582b43b656c819495ddc.

* fixes

* scroll

* scrollbar

* cleanup unrelated

* undo unrel

* flatter

* Fix css

* twk

authored by danabra.mov and committed by

GitHub f015229a cd02922b

+849 -97
+1 -1
bskyweb/templates/base.html
··· 33 } 34 35 html { 36 - scroll-behavior: smooth; 37 /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ 38 -webkit-text-size-adjust: 100%; 39 height: calc(100% + env(safe-area-inset-top)); 40 } 41 42 /* Remove autofill styles on Webkit */
··· 33 } 34 35 html { 36 /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ 37 -webkit-text-size-adjust: 100%; 38 height: calc(100% + env(safe-area-inset-top)); 39 + scrollbar-gutter: stable both-edges; 40 } 41 42 /* Remove autofill styles on Webkit */
+1
package.json
··· 206 "@types/lodash.shuffle": "^4.2.7", 207 "@types/psl": "^1.1.1", 208 "@types/react-avatar-editor": "^13.0.0", 209 "@types/react-responsive": "^8.0.5", 210 "@types/react-test-renderer": "^17.0.1", 211 "@typescript-eslint/eslint-plugin": "^5.48.2",
··· 206 "@types/lodash.shuffle": "^4.2.7", 207 "@types/psl": "^1.1.1", 208 "@types/react-avatar-editor": "^13.0.0", 209 + "@types/react-dom": "^18.2.18", 210 "@types/react-responsive": "^8.0.5", 211 "@types/react-test-renderer": "^17.0.1", 212 "@typescript-eslint/eslint-plugin": "^5.48.2",
+4 -1
src/Navigation.tsx
··· 39 setEmailConfirmationRequested, 40 } from './state/shell/reminders' 41 import {init as initAnalytics} from './lib/analytics/analytics' 42 43 import {HomeScreen} from './view/screens/Home' 44 import {SearchScreen} from './view/screens/Search' ··· 413 const FlatNavigator = () => { 414 const pal = usePalette('default') 415 const numUnread = useUnreadNotifications() 416 - 417 const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread) 418 return ( 419 <Flat.Navigator 420 screenOptions={{ 421 gestureEnabled: true, 422 fullScreenGestureEnabled: true,
··· 39 setEmailConfirmationRequested, 40 } from './state/shell/reminders' 41 import {init as initAnalytics} from './lib/analytics/analytics' 42 + import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' 43 44 import {HomeScreen} from './view/screens/Home' 45 import {SearchScreen} from './view/screens/Search' ··· 414 const FlatNavigator = () => { 415 const pal = usePalette('default') 416 const numUnread = useUnreadNotifications() 417 + const screenListeners = useWebScrollRestoration() 418 const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread) 419 + 420 return ( 421 <Flat.Navigator 422 + screenListeners={screenListeners} 423 screenOptions={{ 424 gestureEnabled: true, 425 fullScreenGestureEnabled: true,
-1
src/lib/batchedUpdates.web.ts
··· 1 - // @ts-ignore 2 export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
··· 1 export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
+28
src/lib/hooks/useWebBodyScrollLock.ts
···
··· 1 + import {useEffect} from 'react' 2 + import {isWeb} from '#/platform/detection' 3 + 4 + let refCount = 0 5 + 6 + function incrementRefCount() { 7 + if (refCount === 0) { 8 + document.body.style.overflow = 'hidden' 9 + } 10 + refCount++ 11 + } 12 + 13 + function decrementRefCount() { 14 + refCount-- 15 + if (refCount === 0) { 16 + document.body.style.overflow = '' 17 + } 18 + } 19 + 20 + export function useWebBodyScrollLock(isLockActive: boolean) { 21 + useEffect(() => { 22 + if (!isWeb || !isLockActive) { 23 + return 24 + } 25 + incrementRefCount() 26 + return () => decrementRefCount() 27 + }) 28 + }
+3
src/lib/hooks/useWebScrollRestoration.native.ts
···
··· 1 + export function useWebScrollRestoration() { 2 + return undefined 3 + }
+52
src/lib/hooks/useWebScrollRestoration.ts
···
··· 1 + import {useMemo, useState, useEffect} from 'react' 2 + import {EventArg, useNavigation} from '@react-navigation/core' 3 + 4 + if ('scrollRestoration' in history) { 5 + // Tell the brower not to mess with the scroll. 6 + // We're doing that manually below. 7 + history.scrollRestoration = 'manual' 8 + } 9 + 10 + function createInitialScrollState() { 11 + return { 12 + scrollYs: new Map(), 13 + focusedKey: null as string | null, 14 + } 15 + } 16 + 17 + export function useWebScrollRestoration() { 18 + const [state] = useState(createInitialScrollState) 19 + const navigation = useNavigation() 20 + 21 + useEffect(() => { 22 + function onDispatch() { 23 + if (state.focusedKey) { 24 + // Remember where we were for later. 25 + state.scrollYs.set(state.focusedKey, window.scrollY) 26 + // TODO: Strictly speaking, this is a leak. We never clean up. 27 + // This is because I'm not sure when it's appropriate to clean it up. 28 + // It doesn't seem like popstate is enough because it can still Forward-Back again. 29 + // Maybe we should use sessionStorage. Or check what Next.js is doing? 30 + } 31 + } 32 + // We want to intercept any push/pop/replace *before* the re-render. 33 + // There is no official way to do this yet, but this works okay for now. 34 + // https://twitter.com/satya164/status/1737301243519725803 35 + navigation.addListener('__unsafe_action__' as any, onDispatch) 36 + return () => { 37 + navigation.removeListener('__unsafe_action__' as any, onDispatch) 38 + } 39 + }, [state, navigation]) 40 + 41 + const screenListeners = useMemo( 42 + () => ({ 43 + focus(e: EventArg<'focus', boolean | undefined, unknown>) { 44 + const scrollY = state.scrollYs.get(e.target) ?? 0 45 + window.scrollTo(0, scrollY) 46 + state.focusedKey = e.target ?? null 47 + }, 48 + }), 49 + [state], 50 + ) 51 + return screenListeners 52 + }
+2 -2
src/lib/styles.ts
··· 1 import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' 2 import {Theme, TypographyVariant} from './ThemeContext' 3 - import {isMobileWeb} from 'platform/detection' 4 5 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest 6 export const colors = { ··· 175 // dimensions 176 w100pct: {width: '100%'}, 177 h100pct: {height: '100%'}, 178 - hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'}, 179 window: { 180 width: Dimensions.get('window').width, 181 height: Dimensions.get('window').height,
··· 1 import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' 2 import {Theme, TypographyVariant} from './ThemeContext' 3 + import {isWeb} from 'platform/detection' 4 5 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest 6 export const colors = { ··· 175 // dimensions 176 w100pct: {width: '100%'}, 177 h100pct: {height: '100%'}, 178 + hContentRegion: isWeb ? {minHeight: '100%'} : {height: '100%'}, 179 window: { 180 width: Dimensions.get('window').width, 181 height: Dimensions.get('window').height,
+2 -1
src/view/com/composer/text-input/web/EmojiPicker.web.tsx
··· 121 122 const styles = StyleSheet.create({ 123 mask: { 124 - position: 'absolute', 125 top: 0, 126 left: 0, 127 right: 0,
··· 121 122 const styles = StyleSheet.create({ 123 mask: { 124 + // @ts-ignore web ony 125 + position: 'fixed', 126 top: 0, 127 left: 0, 128 right: 0,
+1 -10
src/view/com/feeds/FeedPage.tsx
··· 210 const {isDesktop, isTablet} = useWebMediaQueries() 211 const {fontScale} = useWindowDimensions() 212 const {hasSession} = useSession() 213 - 214 - if (isDesktop) { 215 return 0 216 } 217 - if (isTablet) { 218 - if (hasSession) { 219 - return 50 220 - } else { 221 - return 0 222 - } 223 - } 224 - 225 if (hasSession) { 226 const navBarPad = 16 227 const navBarText = 21 * fontScale
··· 210 const {isDesktop, isTablet} = useWebMediaQueries() 211 const {fontScale} = useWindowDimensions() 212 const {hasSession} = useSession() 213 + if (isDesktop || isTablet) { 214 return 0 215 } 216 if (hasSession) { 217 const navBarPad = 16 218 const navBarText = 21 * fontScale
+14 -6
src/view/com/lightbox/Lightbox.web.tsx
··· 1 import React, {useCallback, useEffect, useState} from 'react' 2 import { 3 Image, 4 TouchableOpacity, 5 TouchableWithoutFeedback, 6 StyleSheet, 7 View, 8 Pressable, 9 } from 'react-native' 10 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11 import {colors, s} from 'lib/styles' 12 import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' 13 import {Text} from '../util/text/Text' ··· 19 ImagesLightbox, 20 ProfileImageLightbox, 21 } from '#/state/lightbox' 22 23 interface Img { 24 uri: string ··· 28 export function Lightbox() { 29 const {activeLightbox} = useLightbox() 30 const {closeLightbox} = useLightboxControls() 31 32 - if (!activeLightbox) { 33 return null 34 } 35 ··· 116 <Image 117 accessibilityIgnoresInvertColors 118 source={imgs[index]} 119 - style={styles.image} 120 accessibilityLabel={imgs[index].alt} 121 accessibilityHint="" 122 /> ··· 129 accessibilityHint=""> 130 <FontAwesomeIcon 131 icon="angle-left" 132 - style={styles.icon} 133 size={40} 134 /> 135 </TouchableOpacity> ··· 143 accessibilityHint=""> 144 <FontAwesomeIcon 145 icon="angle-right" 146 - style={styles.icon} 147 size={40} 148 /> 149 </TouchableOpacity> ··· 178 179 const styles = StyleSheet.create({ 180 mask: { 181 - position: 'absolute', 182 top: 0, 183 left: 0, 184 width: '100%',
··· 1 import React, {useCallback, useEffect, useState} from 'react' 2 import { 3 Image, 4 + ImageStyle, 5 TouchableOpacity, 6 TouchableWithoutFeedback, 7 StyleSheet, 8 View, 9 Pressable, 10 } from 'react-native' 11 + import { 12 + FontAwesomeIcon, 13 + FontAwesomeIconStyle, 14 + } from '@fortawesome/react-native-fontawesome' 15 import {colors, s} from 'lib/styles' 16 import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' 17 import {Text} from '../util/text/Text' ··· 23 ImagesLightbox, 24 ProfileImageLightbox, 25 } from '#/state/lightbox' 26 + import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' 27 28 interface Img { 29 uri: string ··· 33 export function Lightbox() { 34 const {activeLightbox} = useLightbox() 35 const {closeLightbox} = useLightboxControls() 36 + const isActive = !!activeLightbox 37 + useWebBodyScrollLock(isActive) 38 39 + if (!isActive) { 40 return null 41 } 42 ··· 123 <Image 124 accessibilityIgnoresInvertColors 125 source={imgs[index]} 126 + style={styles.image as ImageStyle} 127 accessibilityLabel={imgs[index].alt} 128 accessibilityHint="" 129 /> ··· 136 accessibilityHint=""> 137 <FontAwesomeIcon 138 icon="angle-left" 139 + style={styles.icon as FontAwesomeIconStyle} 140 size={40} 141 /> 142 </TouchableOpacity> ··· 150 accessibilityHint=""> 151 <FontAwesomeIcon 152 icon="angle-right" 153 + style={styles.icon as FontAwesomeIconStyle} 154 size={40} 155 /> 156 </TouchableOpacity> ··· 185 186 const styles = StyleSheet.create({ 187 mask: { 188 + // @ts-ignore 189 + position: 'fixed', 190 top: 0, 191 left: 0, 192 width: '100%',
+4 -1
src/view/com/modals/Modal.web.tsx
··· 3 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 import {usePalette} from 'lib/hooks/usePalette' 5 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 6 7 import {useModals, useModalControls} from '#/state/modals' 8 import type {Modal as ModalIface} from '#/state/modals' ··· 38 39 export function ModalsContainer() { 40 const {isModalActive, activeModals} = useModals() 41 42 if (!isModalActive) { 43 return null ··· 166 167 const styles = StyleSheet.create({ 168 mask: { 169 - position: 'absolute', 170 top: 0, 171 left: 0, 172 width: '100%',
··· 3 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' 4 import {usePalette} from 'lib/hooks/usePalette' 5 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 6 + import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' 7 8 import {useModals, useModalControls} from '#/state/modals' 9 import type {Modal as ModalIface} from '#/state/modals' ··· 39 40 export function ModalsContainer() { 41 const {isModalActive, activeModals} = useModals() 42 + useWebBodyScrollLock(isModalActive) 43 44 if (!isModalActive) { 45 return null ··· 168 169 const styles = StyleSheet.create({ 170 mask: { 171 + // @ts-ignore 172 + position: 'fixed', 173 top: 0, 174 left: 0, 175 width: '100%',
+7 -4
src/view/com/pager/FeedsTabBar.web.tsx
··· 117 return ( 118 // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf 119 <Animated.View 120 - style={[pal.view, styles.tabBar, headerMinimalShellTransform]} 121 onLayout={e => { 122 headerHeight.value = e.nativeEvent.layout.height 123 }}> ··· 134 135 const styles = StyleSheet.create({ 136 tabBar: { 137 - position: 'absolute', 138 zIndex: 1, 139 // @ts-ignore Web only -prf 140 - left: 'calc(50% - 299px)', 141 - width: 598, 142 top: 0, 143 flexDirection: 'row', 144 alignItems: 'center', 145 }, 146 })
··· 117 return ( 118 // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf 119 <Animated.View 120 + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} 121 onLayout={e => { 122 headerHeight.value = e.nativeEvent.layout.height 123 }}> ··· 134 135 const styles = StyleSheet.create({ 136 tabBar: { 137 + // @ts-ignore Web only 138 + position: 'sticky', 139 zIndex: 1, 140 // @ts-ignore Web only -prf 141 + left: 'calc(50% - 300px)', 142 + width: 600, 143 top: 0, 144 flexDirection: 'row', 145 alignItems: 'center', 146 + borderLeftWidth: 1, 147 + borderRightWidth: 1, 148 }, 149 })
+2 -1
src/view/com/pager/FeedsTabBarMobile.tsx
··· 142 143 const styles = StyleSheet.create({ 144 tabBar: { 145 - position: 'absolute', 146 zIndex: 1, 147 left: 0, 148 right: 0,
··· 142 143 const styles = StyleSheet.create({ 144 tabBar: { 145 + // @ts-ignore web-only 146 + position: isWeb ? 'fixed' : 'absolute', 147 zIndex: 1, 148 left: 0, 149 right: 0,
+1
src/view/com/pager/Pager.tsx
··· 17 export interface RenderTabBarFnProps { 18 selectedPage: number 19 onSelect?: (index: number) => void 20 } 21 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element 22
··· 17 export interface RenderTabBarFnProps { 18 selectedPage: number 19 onSelect?: (index: number) => void 20 + tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. 21 } 22 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element 23
+35 -16
src/view/com/pager/Pager.web.tsx
··· 1 import React from 'react' 2 import {View} from 'react-native' 3 import {s} from 'lib/styles' 4 5 export interface RenderTabBarFnProps { 6 selectedPage: number 7 onSelect?: (index: number) => void 8 } 9 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element 10 ··· 27 ref, 28 ) { 29 const [selectedPage, setSelectedPage] = React.useState(initialPage) 30 31 React.useImperativeHandle(ref, () => ({ 32 setPage: (index: number) => setSelectedPage(index), ··· 34 35 const onTabBarSelect = React.useCallback( 36 (index: number) => { 37 - setSelectedPage(index) 38 - onPageSelected?.(index) 39 - onPageSelecting?.(index) 40 }, 41 - [setSelectedPage, onPageSelected, onPageSelecting], 42 ) 43 44 return ( ··· 46 {tabBarPosition === 'top' && 47 renderTabBar({ 48 selectedPage, 49 onSelect: onTabBarSelect, 50 })} 51 {React.Children.map(children, (child, i) => ( 52 - <View 53 - style={ 54 - selectedPage === i 55 - ? s.flex1 56 - : { 57 - position: 'absolute', 58 - pointerEvents: 'none', 59 - // @ts-ignore web-only 60 - visibility: 'hidden', 61 - } 62 - } 63 - key={`page-${i}`}> 64 {child} 65 </View> 66 ))}
··· 1 import React from 'react' 2 + import {flushSync} from 'react-dom' 3 import {View} from 'react-native' 4 import {s} from 'lib/styles' 5 6 export interface RenderTabBarFnProps { 7 selectedPage: number 8 onSelect?: (index: number) => void 9 + tabBarAnchor?: JSX.Element 10 } 11 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element 12 ··· 29 ref, 30 ) { 31 const [selectedPage, setSelectedPage] = React.useState(initialPage) 32 + const scrollYs = React.useRef<Array<number | null>>([]) 33 + const anchorRef = React.useRef(null) 34 35 React.useImperativeHandle(ref, () => ({ 36 setPage: (index: number) => setSelectedPage(index), ··· 38 39 const onTabBarSelect = React.useCallback( 40 (index: number) => { 41 + const scrollY = window.scrollY 42 + // We want to determine if the tabbar is already "sticking" at the top (in which 43 + // case we should preserve and restore scroll), or if it is somewhere below in the 44 + // viewport (in which case a scroll jump would be jarring). We determine this by 45 + // measuring where the "anchor" element is (which we place just above the tabbar). 46 + let anchorTop = anchorRef.current 47 + ? (anchorRef.current as Element).getBoundingClientRect().top 48 + : -scrollY // If there's no anchor, treat the top of the page as one. 49 + const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable. 50 + 51 + if (isSticking) { 52 + scrollYs.current[selectedPage] = window.scrollY 53 + } else { 54 + scrollYs.current[selectedPage] = null 55 + } 56 + flushSync(() => { 57 + setSelectedPage(index) 58 + onPageSelected?.(index) 59 + onPageSelecting?.(index) 60 + }) 61 + if (isSticking) { 62 + const restoredScrollY = scrollYs.current[index] 63 + if (restoredScrollY != null) { 64 + window.scrollTo(0, restoredScrollY) 65 + } else { 66 + window.scrollTo(0, scrollY + anchorTop) 67 + } 68 + } 69 }, 70 + [selectedPage, setSelectedPage, onPageSelected, onPageSelecting], 71 ) 72 73 return ( ··· 75 {tabBarPosition === 'top' && 76 renderTabBar({ 77 selectedPage, 78 + tabBarAnchor: <View ref={anchorRef} />, 79 onSelect: onTabBarSelect, 80 })} 81 {React.Children.map(children, (child, i) => ( 82 + <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> 83 {child} 84 </View> 85 ))}
+1 -14
src/view/com/pager/PagerWithHeader.tsx
··· 18 } from 'react-native-reanimated' 19 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 20 import {TabBar} from './TabBar' 21 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 22 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 23 import {ListMethods} from '../util/List' 24 import {ScrollProvider} from '#/lib/ScrollContext' ··· 235 onCurrentPageSelected?: (index: number) => void 236 onSelect?: (index: number) => void 237 }): React.ReactNode => { 238 - const {isMobile} = useWebMediaQueries() 239 const headerTransform = useAnimatedStyle(() => ({ 240 transform: [ 241 { ··· 246 return ( 247 <Animated.View 248 pointerEvents="box-none" 249 - style={[ 250 - isMobile ? styles.tabBarMobile : styles.tabBarDesktop, 251 - headerTransform, 252 - ]}> 253 <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none"> 254 {renderHeader?.()} 255 </View> ··· 324 top: 0, 325 left: 0, 326 width: '100%', 327 - }, 328 - tabBarDesktop: { 329 - position: 'absolute', 330 - zIndex: 1, 331 - top: 0, 332 - // @ts-ignore Web only -prf 333 - left: 'calc(50% - 299px)', 334 - width: 598, 335 }, 336 }) 337
··· 18 } from 'react-native-reanimated' 19 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 20 import {TabBar} from './TabBar' 21 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 22 import {ListMethods} from '../util/List' 23 import {ScrollProvider} from '#/lib/ScrollContext' ··· 234 onCurrentPageSelected?: (index: number) => void 235 onSelect?: (index: number) => void 236 }): React.ReactNode => { 237 const headerTransform = useAnimatedStyle(() => ({ 238 transform: [ 239 { ··· 244 return ( 245 <Animated.View 246 pointerEvents="box-none" 247 + style={[styles.tabBarMobile, headerTransform]}> 248 <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none"> 249 {renderHeader?.()} 250 </View> ··· 319 top: 0, 320 left: 0, 321 width: '100%', 322 }, 323 }) 324
+194
src/view/com/pager/PagerWithHeader.web.tsx
···
··· 1 + import * as React from 'react' 2 + import {FlatList, ScrollView, StyleSheet, View} from 'react-native' 3 + import {useAnimatedRef} from 'react-native-reanimated' 4 + import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' 5 + import {TabBar} from './TabBar' 6 + import {usePalette} from '#/lib/hooks/usePalette' 7 + import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 8 + import {ListMethods} from '../util/List' 9 + 10 + export interface PagerWithHeaderChildParams { 11 + headerHeight: number 12 + isFocused: boolean 13 + scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null> 14 + } 15 + 16 + export interface PagerWithHeaderProps { 17 + testID?: string 18 + children: 19 + | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] 20 + | ((props: PagerWithHeaderChildParams) => JSX.Element) 21 + items: string[] 22 + isHeaderReady: boolean 23 + renderHeader?: () => JSX.Element 24 + initialPage?: number 25 + onPageSelected?: (index: number) => void 26 + onCurrentPageSelected?: (index: number) => void 27 + } 28 + export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( 29 + function PageWithHeaderImpl( 30 + { 31 + children, 32 + testID, 33 + items, 34 + renderHeader, 35 + initialPage, 36 + onPageSelected, 37 + onCurrentPageSelected, 38 + }: PagerWithHeaderProps, 39 + ref, 40 + ) { 41 + const [currentPage, setCurrentPage] = React.useState(0) 42 + 43 + const renderTabBar = React.useCallback( 44 + (props: RenderTabBarFnProps) => { 45 + return ( 46 + <PagerTabBar 47 + items={items} 48 + renderHeader={renderHeader} 49 + currentPage={currentPage} 50 + onCurrentPageSelected={onCurrentPageSelected} 51 + onSelect={props.onSelect} 52 + tabBarAnchor={props.tabBarAnchor} 53 + testID={testID} 54 + /> 55 + ) 56 + }, 57 + [items, renderHeader, currentPage, onCurrentPageSelected, testID], 58 + ) 59 + 60 + const onPageSelectedInner = React.useCallback( 61 + (index: number) => { 62 + setCurrentPage(index) 63 + onPageSelected?.(index) 64 + }, 65 + [onPageSelected, setCurrentPage], 66 + ) 67 + 68 + const onPageSelecting = React.useCallback((index: number) => { 69 + setCurrentPage(index) 70 + }, []) 71 + 72 + return ( 73 + <Pager 74 + ref={ref} 75 + testID={testID} 76 + initialPage={initialPage} 77 + onPageSelected={onPageSelectedInner} 78 + onPageSelecting={onPageSelecting} 79 + renderTabBar={renderTabBar} 80 + tabBarPosition="top"> 81 + {toArray(children) 82 + .filter(Boolean) 83 + .map((child, i) => { 84 + return ( 85 + <View key={i} collapsable={false}> 86 + <PagerItem isFocused={i === currentPage} renderTab={child} /> 87 + </View> 88 + ) 89 + })} 90 + </Pager> 91 + ) 92 + }, 93 + ) 94 + 95 + let PagerTabBar = ({ 96 + currentPage, 97 + items, 98 + testID, 99 + renderHeader, 100 + onCurrentPageSelected, 101 + onSelect, 102 + tabBarAnchor, 103 + }: { 104 + currentPage: number 105 + items: string[] 106 + testID?: string 107 + renderHeader?: () => JSX.Element 108 + onCurrentPageSelected?: (index: number) => void 109 + onSelect?: (index: number) => void 110 + tabBarAnchor?: JSX.Element | null | undefined 111 + }): React.ReactNode => { 112 + const pal = usePalette('default') 113 + const {isMobile} = useWebMediaQueries() 114 + return ( 115 + <> 116 + <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}> 117 + {renderHeader?.()} 118 + </View> 119 + {tabBarAnchor} 120 + <View 121 + style={[ 122 + styles.tabBarContainer, 123 + isMobile 124 + ? styles.tabBarContainerMobile 125 + : styles.tabBarContainerDesktop, 126 + pal.border, 127 + ]}> 128 + <TabBar 129 + testID={testID} 130 + items={items} 131 + selectedPage={currentPage} 132 + onSelect={onSelect} 133 + onPressSelected={onCurrentPageSelected} 134 + /> 135 + </View> 136 + </> 137 + ) 138 + } 139 + PagerTabBar = React.memo(PagerTabBar) 140 + 141 + function PagerItem({ 142 + isFocused, 143 + renderTab, 144 + }: { 145 + isFocused: boolean 146 + renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null 147 + }) { 148 + const scrollElRef = useAnimatedRef() 149 + if (renderTab == null) { 150 + return null 151 + } 152 + return renderTab({ 153 + headerHeight: 0, 154 + isFocused, 155 + scrollElRef: scrollElRef as React.MutableRefObject< 156 + ListMethods | ScrollView | null 157 + >, 158 + }) 159 + } 160 + 161 + const styles = StyleSheet.create({ 162 + headerContainerDesktop: { 163 + marginLeft: 'auto', 164 + marginRight: 'auto', 165 + width: 600, 166 + borderLeftWidth: 1, 167 + borderRightWidth: 1, 168 + }, 169 + tabBarContainer: { 170 + // @ts-ignore web-only 171 + position: 'sticky', 172 + overflow: 'hidden', 173 + top: 0, 174 + zIndex: 1, 175 + }, 176 + tabBarContainerDesktop: { 177 + marginLeft: 'auto', 178 + marginRight: 'auto', 179 + width: 600, 180 + borderLeftWidth: 1, 181 + borderRightWidth: 1, 182 + }, 183 + tabBarContainerMobile: { 184 + paddingLeft: 14, 185 + paddingRight: 14, 186 + }, 187 + }) 188 + 189 + function toArray<T>(v: T | T[]): T[] { 190 + if (Array.isArray(v)) { 191 + return v 192 + } 193 + return [v] 194 + }
+28 -10
src/view/com/post-thread/PostThread.tsx
··· 139 const {hasSession} = useSession() 140 const {_} = useLingui() 141 const pal = usePalette('default') 142 - const {isTablet, isDesktop} = useWebMediaQueries() 143 const ref = useRef<ListMethods>(null) 144 const highlightedPostRef = useRef<View | null>(null) 145 const needsScrollAdjustment = useRef<boolean>( ··· 197 198 // wait for loading to finish 199 if (thread.type === 'post' && !!thread.parent) { 200 - highlightedPostRef.current?.measure( 201 - (_x, _y, _width, _height, _pageX, pageY) => { 202 - ref.current?.scrollToOffset({ 203 - animated: false, 204 - offset: pageY - (isDesktop ? 0 : 50), 205 - }) 206 - }, 207 - ) 208 needsScrollAdjustment.current = false 209 } 210 - }, [thread, isDesktop]) 211 212 const onPTR = React.useCallback(async () => { 213 setIsPTRing(true)
··· 139 const {hasSession} = useSession() 140 const {_} = useLingui() 141 const pal = usePalette('default') 142 + const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries() 143 const ref = useRef<ListMethods>(null) 144 const highlightedPostRef = useRef<View | null>(null) 145 const needsScrollAdjustment = useRef<boolean>( ··· 197 198 // wait for loading to finish 199 if (thread.type === 'post' && !!thread.parent) { 200 + function onMeasure(pageY: number) { 201 + let spinnerHeight = 0 202 + if (isDesktop) { 203 + spinnerHeight = 40 204 + } else if (isTabletOrMobile) { 205 + spinnerHeight = 82 206 + } 207 + ref.current?.scrollToOffset({ 208 + animated: false, 209 + offset: pageY - spinnerHeight, 210 + }) 211 + } 212 + if (isNative) { 213 + highlightedPostRef.current?.measure( 214 + (_x, _y, _width, _height, _pageX, pageY) => { 215 + onMeasure(pageY) 216 + }, 217 + ) 218 + } else { 219 + // Measure synchronously to avoid a layout jump. 220 + const domNode = highlightedPostRef.current 221 + if (domNode) { 222 + const pageY = (domNode as any as Element).getBoundingClientRect().top 223 + onMeasure(pageY) 224 + } 225 + } 226 needsScrollAdjustment.current = false 227 } 228 + }, [thread, isDesktop, isTabletOrMobile]) 229 230 const onPTR = React.useCallback(async () => { 231 setIsPTRing(true)
+2 -4
src/view/com/util/List.tsx
··· 1 - import React, {memo, startTransition} from 'react' 2 import {FlatListProps, RefreshControl} from 'react-native' 3 import {FlatList_INTERNAL} from './Views' 4 import {addStyle} from 'lib/styles' ··· 39 const pal = usePalette('default') 40 41 function handleScrolledDownChange(didScrollDown: boolean) { 42 - startTransition(() => { 43 - onScrolledDownChange?.(didScrollDown) 44 - }) 45 } 46 47 const scrollHandler = useAnimatedScrollHandler({
··· 1 + import React, {memo} from 'react' 2 import {FlatListProps, RefreshControl} from 'react-native' 3 import {FlatList_INTERNAL} from './Views' 4 import {addStyle} from 'lib/styles' ··· 39 const pal = usePalette('default') 40 41 function handleScrolledDownChange(didScrollDown: boolean) { 42 + onScrolledDownChange?.(didScrollDown) 43 } 44 45 const scrollHandler = useAnimatedScrollHandler({
+341
src/view/com/util/List.web.tsx
···
··· 1 + import React, {isValidElement, memo, useRef, startTransition} from 'react' 2 + import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native' 3 + import {addStyle} from 'lib/styles' 4 + import {usePalette} from 'lib/hooks/usePalette' 5 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 6 + import {useScrollHandlers} from '#/lib/ScrollContext' 7 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 8 + import {batchedUpdates} from '#/lib/batchedUpdates' 9 + 10 + export type ListMethods = any // TODO: Better types. 11 + export type ListProps<ItemT> = Omit< 12 + FlatListProps<ItemT>, 13 + | 'onScroll' // Use ScrollContext instead. 14 + | 'refreshControl' // Pass refreshing and/or onRefresh instead. 15 + | 'contentOffset' // Pass headerOffset instead. 16 + > & { 17 + onScrolledDownChange?: (isScrolledDown: boolean) => void 18 + headerOffset?: number 19 + refreshing?: boolean 20 + onRefresh?: () => void 21 + desktopFixedHeight: any // TODO: Better types. 22 + } 23 + export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. 24 + 25 + function ListImpl<ItemT>( 26 + { 27 + ListHeaderComponent, 28 + ListFooterComponent, 29 + contentContainerStyle, 30 + data, 31 + desktopFixedHeight, 32 + headerOffset, 33 + keyExtractor, 34 + refreshing: _unsupportedRefreshing, 35 + onEndReached, 36 + onEndReachedThreshold = 0, 37 + onRefresh: _unsupportedOnRefresh, 38 + onScrolledDownChange, 39 + onContentSizeChange, 40 + renderItem, 41 + extraData, 42 + style, 43 + ...props 44 + }: ListProps<ItemT>, 45 + ref: React.Ref<ListMethods>, 46 + ) { 47 + const contextScrollHandlers = useScrollHandlers() 48 + const pal = usePalette('default') 49 + const {isMobile} = useWebMediaQueries() 50 + if (!isMobile) { 51 + contentContainerStyle = addStyle( 52 + contentContainerStyle, 53 + styles.containerScroll, 54 + ) 55 + } 56 + 57 + let header: JSX.Element | null = null 58 + if (ListHeaderComponent != null) { 59 + if (isValidElement(ListHeaderComponent)) { 60 + header = ListHeaderComponent 61 + } else { 62 + // @ts-ignore Nah it's fine. 63 + header = <ListHeaderComponent /> 64 + } 65 + } 66 + 67 + let footer: JSX.Element | null = null 68 + if (ListFooterComponent != null) { 69 + if (isValidElement(ListFooterComponent)) { 70 + footer = ListFooterComponent 71 + } else { 72 + // @ts-ignore Nah it's fine. 73 + footer = <ListFooterComponent /> 74 + } 75 + } 76 + 77 + if (headerOffset != null) { 78 + style = addStyle(style, { 79 + paddingTop: headerOffset, 80 + }) 81 + } 82 + 83 + const nativeRef = React.useRef(null) 84 + React.useImperativeHandle( 85 + ref, 86 + () => 87 + ({ 88 + scrollToTop() { 89 + window.scrollTo({top: 0}) 90 + }, 91 + scrollToOffset({ 92 + animated, 93 + offset, 94 + }: { 95 + animated: boolean 96 + offset: number 97 + }) { 98 + window.scrollTo({ 99 + left: 0, 100 + top: offset, 101 + behavior: animated ? 'smooth' : 'instant', 102 + }) 103 + }, 104 + } as any), // TODO: Better types. 105 + [], 106 + ) 107 + 108 + // --- onContentSizeChange --- 109 + const containerRef = useRef(null) 110 + useResizeObserver(containerRef, onContentSizeChange) 111 + 112 + // --- onScroll --- 113 + const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false) 114 + const handleWindowScroll = useNonReactiveCallback(() => { 115 + if (isInsideVisibleTree) { 116 + contextScrollHandlers.onScroll?.( 117 + { 118 + contentOffset: { 119 + x: Math.max(0, window.scrollX), 120 + y: Math.max(0, window.scrollY), 121 + }, 122 + } as any, // TODO: Better types. 123 + null as any, 124 + ) 125 + } 126 + }) 127 + React.useEffect(() => { 128 + if (!isInsideVisibleTree) { 129 + // Prevents hidden tabs from firing scroll events. 130 + // Only one list is expected to be firing these at a time. 131 + return 132 + } 133 + window.addEventListener('scroll', handleWindowScroll) 134 + return () => { 135 + window.removeEventListener('scroll', handleWindowScroll) 136 + } 137 + }, [isInsideVisibleTree, handleWindowScroll]) 138 + 139 + // --- onScrolledDownChange --- 140 + const isScrolledDown = useRef(false) 141 + function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) { 142 + const didScrollDown = !isAboveTheFold 143 + if (isScrolledDown.current !== didScrollDown) { 144 + isScrolledDown.current = didScrollDown 145 + startTransition(() => { 146 + onScrolledDownChange?.(didScrollDown) 147 + }) 148 + } 149 + } 150 + 151 + // --- onEndReached --- 152 + const onTailVisibilityChange = useNonReactiveCallback( 153 + (isTailVisible: boolean) => { 154 + if (isTailVisible) { 155 + onEndReached?.({ 156 + distanceFromEnd: onEndReachedThreshold || 0, 157 + }) 158 + } 159 + }, 160 + ) 161 + 162 + return ( 163 + <View {...props} style={style} ref={nativeRef}> 164 + <Visibility 165 + onVisibleChange={setIsInsideVisibleTree} 166 + style={ 167 + // This has position: fixed, so it should always report as visible 168 + // unless we're within a display: none tree (like a hidden tab). 169 + styles.parentTreeVisibilityDetector 170 + } 171 + /> 172 + <View 173 + ref={containerRef} 174 + style={[ 175 + styles.contentContainer, 176 + contentContainerStyle, 177 + desktopFixedHeight ? styles.minHeightViewport : null, 178 + pal.border, 179 + ]}> 180 + <Visibility 181 + onVisibleChange={handleAboveTheFoldVisibleChange} 182 + style={[styles.aboveTheFoldDetector, {height: headerOffset}]} 183 + /> 184 + {header} 185 + {(data as Array<ItemT>).map((item, index) => ( 186 + <Row<ItemT> 187 + key={keyExtractor!(item, index)} 188 + item={item} 189 + index={index} 190 + renderItem={renderItem} 191 + extraData={extraData} 192 + /> 193 + ))} 194 + {onEndReached && ( 195 + <Visibility 196 + topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'} 197 + onVisibleChange={onTailVisibilityChange} 198 + /> 199 + )} 200 + {footer} 201 + </View> 202 + </View> 203 + ) 204 + } 205 + 206 + function useResizeObserver( 207 + ref: React.RefObject<Element>, 208 + onResize: undefined | ((w: number, h: number) => void), 209 + ) { 210 + const handleResize = useNonReactiveCallback(onResize ?? (() => {})) 211 + const isActive = !!onResize 212 + React.useEffect(() => { 213 + if (!isActive) { 214 + return 215 + } 216 + const resizeObserver = new ResizeObserver(entries => { 217 + batchedUpdates(() => { 218 + for (let entry of entries) { 219 + const rect = entry.contentRect 220 + handleResize(rect.width, rect.height) 221 + } 222 + }) 223 + }) 224 + const node = ref.current! 225 + resizeObserver.observe(node) 226 + return () => { 227 + resizeObserver.unobserve(node) 228 + } 229 + }, [handleResize, isActive, ref]) 230 + } 231 + 232 + let Row = function RowImpl<ItemT>({ 233 + item, 234 + index, 235 + renderItem, 236 + extraData: _unused, 237 + }: { 238 + item: ItemT 239 + index: number 240 + renderItem: 241 + | null 242 + | undefined 243 + | ((data: {index: number; item: any; separators: any}) => React.ReactNode) 244 + extraData: any 245 + }): React.ReactNode { 246 + if (!renderItem) { 247 + return null 248 + } 249 + return ( 250 + <View style={styles.row}> 251 + {renderItem({item, index, separators: null as any})} 252 + </View> 253 + ) 254 + } 255 + Row = React.memo(Row) 256 + 257 + let Visibility = ({ 258 + topMargin = '0px', 259 + onVisibleChange, 260 + style, 261 + }: { 262 + topMargin?: string 263 + onVisibleChange: (isVisible: boolean) => void 264 + style?: ViewProps['style'] 265 + }): React.ReactNode => { 266 + const tailRef = React.useRef(null) 267 + const isIntersecting = React.useRef(false) 268 + 269 + const handleIntersection = useNonReactiveCallback( 270 + (entries: IntersectionObserverEntry[]) => { 271 + batchedUpdates(() => { 272 + entries.forEach(entry => { 273 + if (entry.isIntersecting !== isIntersecting.current) { 274 + isIntersecting.current = entry.isIntersecting 275 + onVisibleChange(entry.isIntersecting) 276 + } 277 + }) 278 + }) 279 + }, 280 + ) 281 + 282 + React.useEffect(() => { 283 + const observer = new IntersectionObserver(handleIntersection, { 284 + rootMargin: `${topMargin} 0px 0px 0px`, 285 + }) 286 + const tail: Element | null = tailRef.current! 287 + observer.observe(tail) 288 + return () => { 289 + observer.unobserve(tail) 290 + } 291 + }, [handleIntersection, topMargin]) 292 + 293 + return ( 294 + <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} /> 295 + ) 296 + } 297 + Visibility = React.memo(Visibility) 298 + 299 + export const List = memo(React.forwardRef(ListImpl)) as <ItemT>( 300 + props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>}, 301 + ) => React.ReactElement 302 + 303 + const styles = StyleSheet.create({ 304 + contentContainer: { 305 + borderLeftWidth: 1, 306 + borderRightWidth: 1, 307 + }, 308 + containerScroll: { 309 + width: '100%', 310 + maxWidth: 600, 311 + marginLeft: 'auto', 312 + marginRight: 'auto', 313 + }, 314 + row: { 315 + // @ts-ignore web only 316 + contentVisibility: 'auto', 317 + }, 318 + minHeightViewport: { 319 + // @ts-ignore web only 320 + minHeight: '100vh', 321 + }, 322 + parentTreeVisibilityDetector: { 323 + // @ts-ignore web only 324 + position: 'fixed', 325 + top: 0, 326 + left: 0, 327 + right: 0, 328 + bottom: 0, 329 + }, 330 + aboveTheFoldDetector: { 331 + position: 'absolute', 332 + top: 0, 333 + left: 0, 334 + right: 0, 335 + // Bottom is dynamic. 336 + }, 337 + visibilityDetector: { 338 + pointerEvents: 'none', 339 + zIndex: -1, 340 + }, 341 + })
+35 -2
src/view/com/util/MainScrollProvider.tsx
··· 1 - import React, {useCallback} from 'react' 2 import {ScrollProvider} from '#/lib/ScrollContext' 3 import {NativeScrollEvent} from 'react-native' 4 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' 5 import {useShellLayout} from '#/state/shell/shell-layout' 6 - import {isNative} from 'platform/detection' 7 import {useSharedValue, interpolate} from 'react-native-reanimated' 8 9 const WEB_HIDE_SHELL_THRESHOLD = 200 ··· 19 const setMode = useSetMinimalShellMode() 20 const startDragOffset = useSharedValue<number | null>(null) 21 const startMode = useSharedValue<number | null>(null) 22 23 const onBeginDrag = useCallback( 24 (e: NativeScrollEvent) => { ··· 100 </ScrollProvider> 101 ) 102 }
··· 1 + import React, {useCallback, useEffect} from 'react' 2 + import EventEmitter from 'eventemitter3' 3 import {ScrollProvider} from '#/lib/ScrollContext' 4 import {NativeScrollEvent} from 'react-native' 5 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' 6 import {useShellLayout} from '#/state/shell/shell-layout' 7 + import {isNative, isWeb} from 'platform/detection' 8 import {useSharedValue, interpolate} from 'react-native-reanimated' 9 10 const WEB_HIDE_SHELL_THRESHOLD = 200 ··· 20 const setMode = useSetMinimalShellMode() 21 const startDragOffset = useSharedValue<number | null>(null) 22 const startMode = useSharedValue<number | null>(null) 23 + 24 + useEffect(() => { 25 + if (isWeb) { 26 + return listenToForcedWindowScroll(() => { 27 + startDragOffset.value = null 28 + startMode.value = null 29 + }) 30 + } 31 + }) 32 33 const onBeginDrag = useCallback( 34 (e: NativeScrollEvent) => { ··· 110 </ScrollProvider> 111 ) 112 } 113 + 114 + const emitter = new EventEmitter() 115 + 116 + if (isWeb) { 117 + const originalScroll = window.scroll 118 + window.scroll = function () { 119 + emitter.emit('forced-scroll') 120 + return originalScroll.apply(this, arguments as any) 121 + } 122 + 123 + const originalScrollTo = window.scrollTo 124 + window.scrollTo = function () { 125 + emitter.emit('forced-scroll') 126 + return originalScrollTo.apply(this, arguments as any) 127 + } 128 + } 129 + 130 + function listenToForcedWindowScroll(listener: () => void) { 131 + emitter.addListener('forced-scroll', listener) 132 + return () => { 133 + emitter.removeListener('forced-scroll', listener) 134 + } 135 + }
+15 -1
src/view/com/util/SimpleViewHeader.tsx
··· 14 import {useAnalytics} from 'lib/analytics/analytics' 15 import {NavigationProp} from 'lib/routes/types' 16 import {useSetDrawerOpen} from '#/state/shell' 17 18 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} 19 ··· 47 48 const Container = isMobile ? View : CenteredView 49 return ( 50 - <Container style={[styles.header, isMobile && styles.headerMobile, style]}> 51 {showBackButton ? ( 52 <TouchableOpacity 53 testID="viewHeaderDrawerBtn" ··· 88 headerMobile: { 89 paddingHorizontal: 12, 90 paddingVertical: 10, 91 }, 92 backBtn: { 93 width: 30,
··· 14 import {useAnalytics} from 'lib/analytics/analytics' 15 import {NavigationProp} from 'lib/routes/types' 16 import {useSetDrawerOpen} from '#/state/shell' 17 + import {isWeb} from '#/platform/detection' 18 19 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} 20 ··· 48 49 const Container = isMobile ? View : CenteredView 50 return ( 51 + <Container 52 + style={[ 53 + styles.header, 54 + isMobile && styles.headerMobile, 55 + isWeb && styles.headerWeb, 56 + pal.view, 57 + style, 58 + ]}> 59 {showBackButton ? ( 60 <TouchableOpacity 61 testID="viewHeaderDrawerBtn" ··· 96 headerMobile: { 97 paddingHorizontal: 12, 98 paddingVertical: 10, 99 + }, 100 + headerWeb: { 101 + // @ts-ignore web-only 102 + position: 'sticky', 103 + top: 0, 104 + zIndex: 1, 105 }, 106 backBtn: { 107 width: 30,
+2 -1
src/view/com/util/Toast.web.tsx
··· 64 65 const styles = StyleSheet.create({ 66 container: { 67 - position: 'absolute', 68 left: 20, 69 bottom: 20, 70 // @ts-ignore web only
··· 64 65 const styles = StyleSheet.create({ 66 container: { 67 + // @ts-ignore web only 68 + position: 'fixed', 69 left: 20, 70 bottom: 20, 71 // @ts-ignore web only
+3 -1
src/view/com/util/fab/FABInner.tsx
··· 6 import {useSafeAreaInsets} from 'react-native-safe-area-context' 7 import {clamp} from 'lib/numbers' 8 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' 9 import Animated from 'react-native-reanimated' 10 11 export interface FABProps ··· 64 borderRadius: 35, 65 }, 66 outer: { 67 - position: 'absolute', 68 zIndex: 1, 69 }, 70 inner: {
··· 6 import {useSafeAreaInsets} from 'react-native-safe-area-context' 7 import {clamp} from 'lib/numbers' 8 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' 9 + import {isWeb} from '#/platform/detection' 10 import Animated from 'react-native-reanimated' 11 12 export interface FABProps ··· 65 borderRadius: 35, 66 }, 67 outer: { 68 + // @ts-ignore web-only 69 + position: isWeb ? 'fixed' : 'absolute', 70 zIndex: 1, 71 }, 72 inner: {
+3 -1
src/view/screens/PostThread.tsx
··· 25 import {CenteredView} from '../com/util/Views' 26 import {useComposerControls} from '#/state/shell/composer' 27 import {useSession} from '#/state/session' 28 29 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> 30 export function PostThreadScreen({route}: Props) { ··· 112 113 const styles = StyleSheet.create({ 114 prompt: { 115 - position: 'absolute', 116 left: 0, 117 right: 0, 118 },
··· 25 import {CenteredView} from '../com/util/Views' 26 import {useComposerControls} from '#/state/shell/composer' 27 import {useSession} from '#/state/session' 28 + import {isWeb} from '#/platform/detection' 29 30 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> 31 export function PostThreadScreen({route}: Props) { ··· 113 114 const styles = StyleSheet.create({ 115 prompt: { 116 + // @ts-ignore web-only 117 + position: isWeb ? 'fixed' : 'absolute', 118 left: 0, 119 right: 0, 120 },
+32 -3
src/view/screens/Search/Search.tsx
··· 334 tabBarPosition="top" 335 onPageSelected={onPageSelected} 336 renderTabBar={props => ( 337 - <CenteredView sideBorders style={pal.border}> 338 <TabBar items={SECTIONS_LOGGEDIN} {...props} /> 339 </CenteredView> 340 )} ··· 375 tabBarPosition="top" 376 onPageSelected={onPageSelected} 377 renderTabBar={props => ( 378 - <CenteredView sideBorders style={pal.border}> 379 <TabBar items={SECTIONS_LOGGEDOUT} {...props} /> 380 </CenteredView> 381 )} ··· 466 setDrawerOpen(true) 467 }, [track, setDrawerOpen]) 468 const onPressCancelSearch = React.useCallback(() => { 469 textInput.current?.blur() 470 setQuery('') 471 setShowAutocompleteResults(false) ··· 473 clearTimeout(searchDebounceTimeout.current) 474 }, [textInput]) 475 const onPressClearQuery = React.useCallback(() => { 476 setQuery('') 477 setShowAutocompleteResults(false) 478 }, [setQuery]) 479 const onChangeText = React.useCallback( 480 async (text: string) => { 481 setQuery(text) 482 483 if (text.length > 0) { ··· 506 [setQuery, search, setSearchResults], 507 ) 508 const onSubmit = React.useCallback(() => { 509 setShowAutocompleteResults(false) 510 }, [setShowAutocompleteResults]) 511 512 const onSoftReset = React.useCallback(() => { 513 onPressCancelSearch() 514 }, [onPressCancelSearch]) 515 ··· 526 ) 527 528 return ( 529 - <View style={{flex: 1}}> 530 <CenteredView 531 style={[ 532 styles.header, 533 pal.border, 534 isTabletOrDesktop && {paddingTop: 10}, 535 ]} 536 sideBorders={isTabletOrDesktop}> ··· 661 ) 662 } 663 664 const styles = StyleSheet.create({ 665 header: { 666 flexDirection: 'row', 667 alignItems: 'center', 668 paddingHorizontal: 12, 669 paddingVertical: 4, 670 }, 671 headerMenuBtn: { 672 width: 30, ··· 695 }, 696 headerCancelBtn: { 697 paddingLeft: 10, 698 }, 699 })
··· 334 tabBarPosition="top" 335 onPageSelected={onPageSelected} 336 renderTabBar={props => ( 337 + <CenteredView 338 + sideBorders 339 + style={[pal.border, pal.view, styles.tabBarContainer]}> 340 <TabBar items={SECTIONS_LOGGEDIN} {...props} /> 341 </CenteredView> 342 )} ··· 377 tabBarPosition="top" 378 onPageSelected={onPageSelected} 379 renderTabBar={props => ( 380 + <CenteredView 381 + sideBorders 382 + style={[pal.border, pal.view, styles.tabBarContainer]}> 383 <TabBar items={SECTIONS_LOGGEDOUT} {...props} /> 384 </CenteredView> 385 )} ··· 470 setDrawerOpen(true) 471 }, [track, setDrawerOpen]) 472 const onPressCancelSearch = React.useCallback(() => { 473 + scrollToTopWeb() 474 textInput.current?.blur() 475 setQuery('') 476 setShowAutocompleteResults(false) ··· 478 clearTimeout(searchDebounceTimeout.current) 479 }, [textInput]) 480 const onPressClearQuery = React.useCallback(() => { 481 + scrollToTopWeb() 482 setQuery('') 483 setShowAutocompleteResults(false) 484 }, [setQuery]) 485 const onChangeText = React.useCallback( 486 async (text: string) => { 487 + scrollToTopWeb() 488 setQuery(text) 489 490 if (text.length > 0) { ··· 513 [setQuery, search, setSearchResults], 514 ) 515 const onSubmit = React.useCallback(() => { 516 + scrollToTopWeb() 517 setShowAutocompleteResults(false) 518 }, [setShowAutocompleteResults]) 519 520 const onSoftReset = React.useCallback(() => { 521 + scrollToTopWeb() 522 onPressCancelSearch() 523 }, [onPressCancelSearch]) 524 ··· 535 ) 536 537 return ( 538 + <View style={isWeb ? null : {flex: 1}}> 539 <CenteredView 540 style={[ 541 styles.header, 542 pal.border, 543 + pal.view, 544 isTabletOrDesktop && {paddingTop: 10}, 545 ]} 546 sideBorders={isTabletOrDesktop}> ··· 671 ) 672 } 673 674 + function scrollToTopWeb() { 675 + if (isWeb) { 676 + window.scrollTo(0, 0) 677 + } 678 + } 679 + 680 + const HEADER_HEIGHT = 50 681 + 682 const styles = StyleSheet.create({ 683 header: { 684 flexDirection: 'row', 685 alignItems: 'center', 686 paddingHorizontal: 12, 687 paddingVertical: 4, 688 + height: HEADER_HEIGHT, 689 + // @ts-ignore web only 690 + position: isWeb ? 'sticky' : '', 691 + top: 0, 692 + zIndex: 1, 693 }, 694 headerMenuBtn: { 695 width: 30, ··· 718 }, 719 headerCancelBtn: { 720 paddingLeft: 10, 721 + }, 722 + tabBarContainer: { 723 + // @ts-ignore web only 724 + position: isWeb ? 'sticky' : '', 725 + top: isWeb ? HEADER_HEIGHT : 0, 726 + zIndex: 1, 727 }, 728 })
+6 -2
src/view/shell/Composer.web.tsx
··· 5 import {useComposerState} from 'state/shell/composer' 6 import {usePalette} from 'lib/hooks/usePalette' 7 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 8 import { 9 EmojiPicker, 10 EmojiPickerState, ··· 16 const pal = usePalette('default') 17 const {isMobile} = useWebMediaQueries() 18 const state = useComposerState() 19 20 const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ 21 isOpen: false, ··· 40 // rendering 41 // = 42 43 - if (!state) { 44 return <View /> 45 } 46 ··· 75 76 const styles = StyleSheet.create({ 77 mask: { 78 - position: 'absolute', 79 top: 0, 80 left: 0, 81 width: '100%',
··· 5 import {useComposerState} from 'state/shell/composer' 6 import {usePalette} from 'lib/hooks/usePalette' 7 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 8 + import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' 9 import { 10 EmojiPicker, 11 EmojiPickerState, ··· 17 const pal = usePalette('default') 18 const {isMobile} = useWebMediaQueries() 19 const state = useComposerState() 20 + const isActive = !!state 21 + useWebBodyScrollLock(isActive) 22 23 const [pickerState, setPickerState] = React.useState<EmojiPickerState>({ 24 isOpen: false, ··· 43 // rendering 44 // = 45 46 + if (!isActive) { 47 return <View /> 48 } 49 ··· 78 79 const styles = StyleSheet.create({ 80 mask: { 81 + // @ts-ignore 82 + position: 'fixed', 83 top: 0, 84 left: 0, 85 width: '100%',
+4
src/view/shell/bottom-bar/BottomBarStyles.tsx
··· 12 paddingLeft: 5, 13 paddingRight: 10, 14 }, 15 ctrl: { 16 flex: 1, 17 paddingTop: 13,
··· 12 paddingLeft: 5, 13 paddingRight: 10, 14 }, 15 + bottomBarWeb: { 16 + // @ts-ignore web-only 17 + position: 'fixed', 18 + }, 19 ctrl: { 20 flex: 1, 21 paddingTop: 13,
+1
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 57 <Animated.View 58 style={[ 59 styles.bottomBar, 60 pal.view, 61 pal.border, 62 {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
··· 57 <Animated.View 58 style={[ 59 styles.bottomBar, 60 + styles.bottomBarWeb, 61 pal.view, 62 pal.border, 63 {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
+3 -2
src/view/shell/desktop/LeftNav.tsx
··· 442 443 const styles = StyleSheet.create({ 444 leftNav: { 445 - position: 'absolute', 446 top: 10, 447 // @ts-ignore web only 448 - right: 'calc(50vw + 312px)', 449 width: 220, 450 // @ts-ignore web only 451 maxHeight: 'calc(100vh - 10px)',
··· 442 443 const styles = StyleSheet.create({ 444 leftNav: { 445 + // @ts-ignore web only 446 + position: 'fixed', 447 top: 10, 448 // @ts-ignore web only 449 + left: 'calc(50vw - 300px - 220px - 20px)', 450 width: 220, 451 // @ts-ignore web only 452 maxHeight: 'calc(100vh - 10px)',
+3 -2
src/view/shell/desktop/RightNav.tsx
··· 177 178 const styles = StyleSheet.create({ 179 rightNav: { 180 - position: 'absolute', 181 // @ts-ignore web only 182 - left: 'calc(50vw + 320px)', 183 width: 300, 184 maxHeight: '100%', 185 overflowY: 'auto',
··· 177 178 const styles = StyleSheet.create({ 179 rightNav: { 180 + // @ts-ignore web only 181 + position: 'fixed', 182 // @ts-ignore web only 183 + left: 'calc(50vw + 300px + 20px)', 184 width: 300, 185 maxHeight: '100%', 186 overflowY: 'auto',
+11 -9
src/view/shell/index.web.tsx
··· 15 import {t} from '@lingui/macro' 16 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 17 import {useCloseAllActiveElements} from '#/state/util' 18 import {Outlet as PortalOutlet} from '#/components/Portal' 19 20 function ShellInner() { ··· 24 const navigator = useNavigation<NavigationProp>() 25 const closeAllActiveElements = useCloseAllActiveElements() 26 27 useAuxClick() 28 29 useEffect(() => { ··· 34 }, [navigator, closeAllActiveElements]) 35 36 return ( 37 - <View style={[s.hContentRegion, {overflow: 'hidden'}]}> 38 - <View style={s.hContentRegion}> 39 - <ErrorBoundary> 40 - <FlatNavigator /> 41 - </ErrorBoundary> 42 - </View> 43 <Composer winHeight={0} /> 44 <ModalsContainer /> 45 <PortalOutlet /> ··· 55 </View> 56 </TouchableOpacity> 57 )} 58 - </View> 59 ) 60 } 61 ··· 78 backgroundColor: colors.black, // TODO 79 }, 80 drawerMask: { 81 - position: 'absolute', 82 width: '100%', 83 height: '100%', 84 top: 0, ··· 87 }, 88 drawerContainer: { 89 display: 'flex', 90 - position: 'absolute', 91 top: 0, 92 left: 0, 93 height: '100%',
··· 15 import {t} from '@lingui/macro' 16 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 17 import {useCloseAllActiveElements} from '#/state/util' 18 + import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' 19 import {Outlet as PortalOutlet} from '#/components/Portal' 20 21 function ShellInner() { ··· 25 const navigator = useNavigation<NavigationProp>() 26 const closeAllActiveElements = useCloseAllActiveElements() 27 28 + useWebBodyScrollLock(isDrawerOpen) 29 useAuxClick() 30 31 useEffect(() => { ··· 36 }, [navigator, closeAllActiveElements]) 37 38 return ( 39 + <> 40 + <ErrorBoundary> 41 + <FlatNavigator /> 42 + </ErrorBoundary> 43 <Composer winHeight={0} /> 44 <ModalsContainer /> 45 <PortalOutlet /> ··· 55 </View> 56 </TouchableOpacity> 57 )} 58 + </> 59 ) 60 } 61 ··· 78 backgroundColor: colors.black, // TODO 79 }, 80 drawerMask: { 81 + // @ts-ignore web only 82 + position: 'fixed', 83 width: '100%', 84 height: '100%', 85 top: 0, ··· 88 }, 89 drawerContainer: { 90 display: 'flex', 91 + // @ts-ignore web only 92 + position: 'fixed', 93 top: 0, 94 left: 0, 95 height: '100%',
+1 -1
web/index.html
··· 37 } 38 39 html { 40 - scroll-behavior: smooth; 41 /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ 42 -webkit-text-size-adjust: 100%; 43 height: calc(100% + env(safe-area-inset-top)); 44 } 45 46 /* Remove autofill styles on Webkit */
··· 37 } 38 39 html { 40 /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ 41 -webkit-text-size-adjust: 100%; 42 height: calc(100% + env(safe-area-inset-top)); 43 + scrollbar-gutter: stable; 44 } 45 46 /* Remove autofill styles on Webkit */
+7
yarn.lock
··· 7525 dependencies: 7526 "@types/react" "*" 7527 7528 "@types/react-responsive@^8.0.5": 7529 version "8.0.5" 7530 resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.5.tgz#77769862d2a0711434feb972be08e3e6c334440a"
··· 7525 dependencies: 7526 "@types/react" "*" 7527 7528 + "@types/react-dom@^18.2.18": 7529 + version "18.2.18" 7530 + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd" 7531 + integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw== 7532 + dependencies: 7533 + "@types/react" "*" 7534 + 7535 "@types/react-responsive@^8.0.5": 7536 version "8.0.5" 7537 resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.5.tgz#77769862d2a0711434feb972be08e3e6c334440a"