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
zio/stable
dan 2024-01-22 22:46:32 +00:00 committed by GitHub
parent cd02922b03
commit f015229acf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 849 additions and 97 deletions

View File

@ -33,10 +33,10 @@
}
html {
scroll-behavior: smooth;
/* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
-webkit-text-size-adjust: 100%;
height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges;
}
/* Remove autofill styles on Webkit */

View File

@ -206,6 +206,7 @@
"@types/lodash.shuffle": "^4.2.7",
"@types/psl": "^1.1.1",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-dom": "^18.2.18",
"@types/react-responsive": "^8.0.5",
"@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2",

View File

@ -39,6 +39,7 @@ import {
setEmailConfirmationRequested,
} from './state/shell/reminders'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {HomeScreen} from './view/screens/Home'
import {SearchScreen} from './view/screens/Search'
@ -413,10 +414,12 @@ function MyProfileTabNavigator() {
const FlatNavigator = () => {
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
return (
<Flat.Navigator
screenListeners={screenListeners}
screenOptions={{
gestureEnabled: true,
fullScreenGestureEnabled: true,

View File

@ -1,2 +1 @@
// @ts-ignore
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'

View File

@ -0,0 +1,28 @@
import {useEffect} from 'react'
import {isWeb} from '#/platform/detection'
let refCount = 0
function incrementRefCount() {
if (refCount === 0) {
document.body.style.overflow = 'hidden'
}
refCount++
}
function decrementRefCount() {
refCount--
if (refCount === 0) {
document.body.style.overflow = ''
}
}
export function useWebBodyScrollLock(isLockActive: boolean) {
useEffect(() => {
if (!isWeb || !isLockActive) {
return
}
incrementRefCount()
return () => decrementRefCount()
})
}

View File

@ -0,0 +1,3 @@
export function useWebScrollRestoration() {
return undefined
}

View File

@ -0,0 +1,52 @@
import {useMemo, useState, useEffect} from 'react'
import {EventArg, useNavigation} from '@react-navigation/core'
if ('scrollRestoration' in history) {
// Tell the brower not to mess with the scroll.
// We're doing that manually below.
history.scrollRestoration = 'manual'
}
function createInitialScrollState() {
return {
scrollYs: new Map(),
focusedKey: null as string | null,
}
}
export function useWebScrollRestoration() {
const [state] = useState(createInitialScrollState)
const navigation = useNavigation()
useEffect(() => {
function onDispatch() {
if (state.focusedKey) {
// Remember where we were for later.
state.scrollYs.set(state.focusedKey, window.scrollY)
// TODO: Strictly speaking, this is a leak. We never clean up.
// This is because I'm not sure when it's appropriate to clean it up.
// It doesn't seem like popstate is enough because it can still Forward-Back again.
// Maybe we should use sessionStorage. Or check what Next.js is doing?
}
}
// We want to intercept any push/pop/replace *before* the re-render.
// There is no official way to do this yet, but this works okay for now.
// https://twitter.com/satya164/status/1737301243519725803
navigation.addListener('__unsafe_action__' as any, onDispatch)
return () => {
navigation.removeListener('__unsafe_action__' as any, onDispatch)
}
}, [state, navigation])
const screenListeners = useMemo(
() => ({
focus(e: EventArg<'focus', boolean | undefined, unknown>) {
const scrollY = state.scrollYs.get(e.target) ?? 0
window.scrollTo(0, scrollY)
state.focusedKey = e.target ?? null
},
}),
[state],
)
return screenListeners
}

View File

@ -1,6 +1,6 @@
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
import {Theme, TypographyVariant} from './ThemeContext'
import {isMobileWeb} from 'platform/detection'
import {isWeb} from 'platform/detection'
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
export const colors = {
@ -175,7 +175,7 @@ export const s = StyleSheet.create({
// dimensions
w100pct: {width: '100%'},
h100pct: {height: '100%'},
hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
hContentRegion: isWeb ? {minHeight: '100%'} : {height: '100%'},
window: {
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,

View File

@ -121,7 +121,8 @@ export function EmojiPicker({state, close}: IProps) {
const styles = StyleSheet.create({
mask: {
position: 'absolute',
// @ts-ignore web ony
position: 'fixed',
top: 0,
left: 0,
right: 0,

View File

@ -210,18 +210,9 @@ function useHeaderOffset() {
const {isDesktop, isTablet} = useWebMediaQueries()
const {fontScale} = useWindowDimensions()
const {hasSession} = useSession()
if (isDesktop) {
if (isDesktop || isTablet) {
return 0
}
if (isTablet) {
if (hasSession) {
return 50
} else {
return 0
}
}
if (hasSession) {
const navBarPad = 16
const navBarText = 21 * fontScale

View File

@ -1,13 +1,17 @@
import React, {useCallback, useEffect, useState} from 'react'
import {
Image,
ImageStyle,
TouchableOpacity,
TouchableWithoutFeedback,
StyleSheet,
View,
Pressable,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {colors, s} from 'lib/styles'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
import {Text} from '../util/text/Text'
@ -19,6 +23,7 @@ import {
ImagesLightbox,
ProfileImageLightbox,
} from '#/state/lightbox'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
interface Img {
uri: string
@ -28,8 +33,10 @@ interface Img {
export function Lightbox() {
const {activeLightbox} = useLightbox()
const {closeLightbox} = useLightboxControls()
const isActive = !!activeLightbox
useWebBodyScrollLock(isActive)
if (!activeLightbox) {
if (!isActive) {
return null
}
@ -116,7 +123,7 @@ function LightboxInner({
<Image
accessibilityIgnoresInvertColors
source={imgs[index]}
style={styles.image}
style={styles.image as ImageStyle}
accessibilityLabel={imgs[index].alt}
accessibilityHint=""
/>
@ -129,7 +136,7 @@ function LightboxInner({
accessibilityHint="">
<FontAwesomeIcon
icon="angle-left"
style={styles.icon}
style={styles.icon as FontAwesomeIconStyle}
size={40}
/>
</TouchableOpacity>
@ -143,7 +150,7 @@ function LightboxInner({
accessibilityHint="">
<FontAwesomeIcon
icon="angle-right"
style={styles.icon}
style={styles.icon as FontAwesomeIconStyle}
size={40}
/>
</TouchableOpacity>
@ -178,7 +185,8 @@ function LightboxInner({
const styles = StyleSheet.create({
mask: {
position: 'absolute',
// @ts-ignore
position: 'fixed',
top: 0,
left: 0,
width: '100%',

View File

@ -3,6 +3,7 @@ import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals'
@ -38,6 +39,7 @@ import * as EmbedConsentModal from './EmbedConsent'
export function ModalsContainer() {
const {isModalActive, activeModals} = useModals()
useWebBodyScrollLock(isModalActive)
if (!isModalActive) {
return null
@ -166,7 +168,8 @@ function Modal({modal}: {modal: ModalIface}) {
const styles = StyleSheet.create({
mask: {
position: 'absolute',
// @ts-ignore
position: 'fixed',
top: 0,
left: 0,
width: '100%',

View File

@ -117,7 +117,7 @@ function FeedsTabBarTablet(
return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
<Animated.View
style={[pal.view, styles.tabBar, headerMinimalShellTransform]}
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height
}}>
@ -134,13 +134,16 @@ function FeedsTabBarTablet(
const styles = StyleSheet.create({
tabBar: {
position: 'absolute',
// @ts-ignore Web only
position: 'sticky',
zIndex: 1,
// @ts-ignore Web only -prf
left: 'calc(50% - 299px)',
width: 598,
left: 'calc(50% - 300px)',
width: 600,
top: 0,
flexDirection: 'row',
alignItems: 'center',
borderLeftWidth: 1,
borderRightWidth: 1,
},
})

View File

@ -142,7 +142,8 @@ export function FeedsTabBar(
const styles = StyleSheet.create({
tabBar: {
position: 'absolute',
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
left: 0,
right: 0,

View File

@ -17,6 +17,7 @@ export interface PagerRef {
export interface RenderTabBarFnProps {
selectedPage: number
onSelect?: (index: number) => void
tabBarAnchor?: JSX.Element | null | undefined // Ignored on native.
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element

View File

@ -1,10 +1,12 @@
import React from 'react'
import {flushSync} from 'react-dom'
import {View} from 'react-native'
import {s} from 'lib/styles'
export interface RenderTabBarFnProps {
selectedPage: number
onSelect?: (index: number) => void
tabBarAnchor?: JSX.Element
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
@ -27,6 +29,8 @@ export const Pager = React.forwardRef(function PagerImpl(
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const scrollYs = React.useRef<Array<number | null>>([])
const anchorRef = React.useRef(null)
React.useImperativeHandle(ref, () => ({
setPage: (index: number) => setSelectedPage(index),
@ -34,11 +38,36 @@ export const Pager = React.forwardRef(function PagerImpl(
const onTabBarSelect = React.useCallback(
(index: number) => {
const scrollY = window.scrollY
// We want to determine if the tabbar is already "sticking" at the top (in which
// case we should preserve and restore scroll), or if it is somewhere below in the
// viewport (in which case a scroll jump would be jarring). We determine this by
// measuring where the "anchor" element is (which we place just above the tabbar).
let anchorTop = anchorRef.current
? (anchorRef.current as Element).getBoundingClientRect().top
: -scrollY // If there's no anchor, treat the top of the page as one.
const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable.
if (isSticking) {
scrollYs.current[selectedPage] = window.scrollY
} else {
scrollYs.current[selectedPage] = null
}
flushSync(() => {
setSelectedPage(index)
onPageSelected?.(index)
onPageSelecting?.(index)
})
if (isSticking) {
const restoredScrollY = scrollYs.current[index]
if (restoredScrollY != null) {
window.scrollTo(0, restoredScrollY)
} else {
window.scrollTo(0, scrollY + anchorTop)
}
}
},
[setSelectedPage, onPageSelected, onPageSelecting],
[selectedPage, setSelectedPage, onPageSelected, onPageSelecting],
)
return (
@ -46,21 +75,11 @@ export const Pager = React.forwardRef(function PagerImpl(
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
tabBarAnchor: <View ref={anchorRef} />,
onSelect: onTabBarSelect,
})}
{React.Children.map(children, (child, i) => (
<View
style={
selectedPage === i
? s.flex1
: {
position: 'absolute',
pointerEvents: 'none',
// @ts-ignore web-only
visibility: 'hidden',
}
}
key={`page-${i}`}>
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
{child}
</View>
))}

View File

@ -18,7 +18,6 @@ import Animated, {
} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ListMethods} from '../util/List'
import {ScrollProvider} from '#/lib/ScrollContext'
@ -235,7 +234,6 @@ let PagerTabBar = ({
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
}): React.ReactNode => {
const {isMobile} = useWebMediaQueries()
const headerTransform = useAnimatedStyle(() => ({
transform: [
{
@ -246,10 +244,7 @@ let PagerTabBar = ({
return (
<Animated.View
pointerEvents="box-none"
style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
style={[styles.tabBarMobile, headerTransform]}>
<View onLayout={onHeaderOnlyLayout} pointerEvents="box-none">
{renderHeader?.()}
</View>
@ -325,14 +320,6 @@ const styles = StyleSheet.create({
left: 0,
width: '100%',
},
tabBarDesktop: {
position: 'absolute',
zIndex: 1,
top: 0,
// @ts-ignore Web only -prf
left: 'calc(50% - 299px)',
width: 598,
},
})
function noop() {

View File

@ -0,0 +1,194 @@
import * as React from 'react'
import {FlatList, ScrollView, StyleSheet, View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {ListMethods} from '../util/List'
export interface PagerWithHeaderChildParams {
headerHeight: number
isFocused: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
}
export interface PagerWithHeaderProps {
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[]
isHeaderReady: boolean
renderHeader?: () => JSX.Element
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
items,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
}: PagerWithHeaderProps,
ref,
) {
const [currentPage, setCurrentPage] = React.useState(0)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerTabBar
items={items}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onSelect={props.onSelect}
tabBarAnchor={props.tabBarAnchor}
testID={testID}
/>
)
},
[items, renderHeader, currentPage, onCurrentPageSelected, testID],
)
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback((index: number) => {
setCurrentPage(index)
}, [])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}
tabBarPosition="top">
{toArray(children)
.filter(Boolean)
.map((child, i) => {
return (
<View key={i} collapsable={false}>
<PagerItem isFocused={i === currentPage} renderTab={child} />
</View>
)
})}
</Pager>
)
},
)
let PagerTabBar = ({
currentPage,
items,
testID,
renderHeader,
onCurrentPageSelected,
onSelect,
tabBarAnchor,
}: {
currentPage: number
items: string[]
testID?: string
renderHeader?: () => JSX.Element
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
tabBarAnchor?: JSX.Element | null | undefined
}): React.ReactNode => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
<>
<View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
{renderHeader?.()}
</View>
{tabBarAnchor}
<View
style={[
styles.tabBarContainer,
isMobile
? styles.tabBarContainerMobile
: styles.tabBarContainerDesktop,
pal.border,
]}>
<TabBar
testID={testID}
items={items}
selectedPage={currentPage}
onSelect={onSelect}
onPressSelected={onCurrentPageSelected}
/>
</View>
</>
)
}
PagerTabBar = React.memo(PagerTabBar)
function PagerItem({
isFocused,
renderTab,
}: {
isFocused: boolean
renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
}) {
const scrollElRef = useAnimatedRef()
if (renderTab == null) {
return null
}
return renderTab({
headerHeight: 0,
isFocused,
scrollElRef: scrollElRef as React.MutableRefObject<
ListMethods | ScrollView | null
>,
})
}
const styles = StyleSheet.create({
headerContainerDesktop: {
marginLeft: 'auto',
marginRight: 'auto',
width: 600,
borderLeftWidth: 1,
borderRightWidth: 1,
},
tabBarContainer: {
// @ts-ignore web-only
position: 'sticky',
overflow: 'hidden',
top: 0,
zIndex: 1,
},
tabBarContainerDesktop: {
marginLeft: 'auto',
marginRight: 'auto',
width: 600,
borderLeftWidth: 1,
borderRightWidth: 1,
},
tabBarContainerMobile: {
paddingLeft: 14,
paddingRight: 14,
},
})
function toArray<T>(v: T | T[]): T[] {
if (Array.isArray(v)) {
return v
}
return [v]
}

View File

@ -139,7 +139,7 @@ function PostThreadLoaded({
const {hasSession} = useSession()
const {_} = useLingui()
const pal = usePalette('default')
const {isTablet, isDesktop} = useWebMediaQueries()
const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries()
const ref = useRef<ListMethods>(null)
const highlightedPostRef = useRef<View | null>(null)
const needsScrollAdjustment = useRef<boolean>(
@ -197,17 +197,35 @@ function PostThreadLoaded({
// wait for loading to finish
if (thread.type === 'post' && !!thread.parent) {
highlightedPostRef.current?.measure(
(_x, _y, _width, _height, _pageX, pageY) => {
function onMeasure(pageY: number) {
let spinnerHeight = 0
if (isDesktop) {
spinnerHeight = 40
} else if (isTabletOrMobile) {
spinnerHeight = 82
}
ref.current?.scrollToOffset({
animated: false,
offset: pageY - (isDesktop ? 0 : 50),
offset: pageY - spinnerHeight,
})
}
if (isNative) {
highlightedPostRef.current?.measure(
(_x, _y, _width, _height, _pageX, pageY) => {
onMeasure(pageY)
},
)
} else {
// Measure synchronously to avoid a layout jump.
const domNode = highlightedPostRef.current
if (domNode) {
const pageY = (domNode as any as Element).getBoundingClientRect().top
onMeasure(pageY)
}
}
needsScrollAdjustment.current = false
}
}, [thread, isDesktop])
}, [thread, isDesktop, isTabletOrMobile])
const onPTR = React.useCallback(async () => {
setIsPTRing(true)

View File

@ -1,4 +1,4 @@
import React, {memo, startTransition} from 'react'
import React, {memo} from 'react'
import {FlatListProps, RefreshControl} from 'react-native'
import {FlatList_INTERNAL} from './Views'
import {addStyle} from 'lib/styles'
@ -39,9 +39,7 @@ function ListImpl<ItemT>(
const pal = usePalette('default')
function handleScrolledDownChange(didScrollDown: boolean) {
startTransition(() => {
onScrolledDownChange?.(didScrollDown)
})
}
const scrollHandler = useAnimatedScrollHandler({

View File

@ -0,0 +1,341 @@
import React, {isValidElement, memo, useRef, startTransition} from 'react'
import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
import {addStyle} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {batchedUpdates} from '#/lib/batchedUpdates'
export type ListMethods = any // TODO: Better types.
export type ListProps<ItemT> = Omit<
FlatListProps<ItemT>,
| 'onScroll' // Use ScrollContext instead.
| 'refreshControl' // Pass refreshing and/or onRefresh instead.
| 'contentOffset' // Pass headerOffset instead.
> & {
onScrolledDownChange?: (isScrolledDown: boolean) => void
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
desktopFixedHeight: any // TODO: Better types.
}
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
function ListImpl<ItemT>(
{
ListHeaderComponent,
ListFooterComponent,
contentContainerStyle,
data,
desktopFixedHeight,
headerOffset,
keyExtractor,
refreshing: _unsupportedRefreshing,
onEndReached,
onEndReachedThreshold = 0,
onRefresh: _unsupportedOnRefresh,
onScrolledDownChange,
onContentSizeChange,
renderItem,
extraData,
style,
...props
}: ListProps<ItemT>,
ref: React.Ref<ListMethods>,
) {
const contextScrollHandlers = useScrollHandlers()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
if (!isMobile) {
contentContainerStyle = addStyle(
contentContainerStyle,
styles.containerScroll,
)
}
let header: JSX.Element | null = null
if (ListHeaderComponent != null) {
if (isValidElement(ListHeaderComponent)) {
header = ListHeaderComponent
} else {
// @ts-ignore Nah it's fine.
header = <ListHeaderComponent />
}
}
let footer: JSX.Element | null = null
if (ListFooterComponent != null) {
if (isValidElement(ListFooterComponent)) {
footer = ListFooterComponent
} else {
// @ts-ignore Nah it's fine.
footer = <ListFooterComponent />
}
}
if (headerOffset != null) {
style = addStyle(style, {
paddingTop: headerOffset,
})
}
const nativeRef = React.useRef(null)
React.useImperativeHandle(
ref,
() =>
({
scrollToTop() {
window.scrollTo({top: 0})
},
scrollToOffset({
animated,
offset,
}: {
animated: boolean
offset: number
}) {
window.scrollTo({
left: 0,
top: offset,
behavior: animated ? 'smooth' : 'instant',
})
},
} as any), // TODO: Better types.
[],
)
// --- onContentSizeChange ---
const containerRef = useRef(null)
useResizeObserver(containerRef, onContentSizeChange)
// --- onScroll ---
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
const handleWindowScroll = useNonReactiveCallback(() => {
if (isInsideVisibleTree) {
contextScrollHandlers.onScroll?.(
{
contentOffset: {
x: Math.max(0, window.scrollX),
y: Math.max(0, window.scrollY),
},
} as any, // TODO: Better types.
null as any,
)
}
})
React.useEffect(() => {
if (!isInsideVisibleTree) {
// Prevents hidden tabs from firing scroll events.
// Only one list is expected to be firing these at a time.
return
}
window.addEventListener('scroll', handleWindowScroll)
return () => {
window.removeEventListener('scroll', handleWindowScroll)
}
}, [isInsideVisibleTree, handleWindowScroll])
// --- onScrolledDownChange ---
const isScrolledDown = useRef(false)
function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
const didScrollDown = !isAboveTheFold
if (isScrolledDown.current !== didScrollDown) {
isScrolledDown.current = didScrollDown
startTransition(() => {
onScrolledDownChange?.(didScrollDown)
})
}
}
// --- onEndReached ---
const onTailVisibilityChange = useNonReactiveCallback(
(isTailVisible: boolean) => {
if (isTailVisible) {
onEndReached?.({
distanceFromEnd: onEndReachedThreshold || 0,
})
}
},
)
return (
<View {...props} style={style} ref={nativeRef}>
<Visibility
onVisibleChange={setIsInsideVisibleTree}
style={
// This has position: fixed, so it should always report as visible
// unless we're within a display: none tree (like a hidden tab).
styles.parentTreeVisibilityDetector
}
/>
<View
ref={containerRef}
style={[
styles.contentContainer,
contentContainerStyle,
desktopFixedHeight ? styles.minHeightViewport : null,
pal.border,
]}>
<Visibility
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{header}
{(data as Array<ItemT>).map((item, index) => (
<Row<ItemT>
key={keyExtractor!(item, index)}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
/>
))}
{onEndReached && (
<Visibility
topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
onVisibleChange={onTailVisibilityChange}
/>
)}
{footer}
</View>
</View>
)
}
function useResizeObserver(
ref: React.RefObject<Element>,
onResize: undefined | ((w: number, h: number) => void),
) {
const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
const isActive = !!onResize
React.useEffect(() => {
if (!isActive) {
return
}
const resizeObserver = new ResizeObserver(entries => {
batchedUpdates(() => {
for (let entry of entries) {
const rect = entry.contentRect
handleResize(rect.width, rect.height)
}
})
})
const node = ref.current!
resizeObserver.observe(node)
return () => {
resizeObserver.unobserve(node)
}
}, [handleResize, isActive, ref])
}
let Row = function RowImpl<ItemT>({
item,
index,
renderItem,
extraData: _unused,
}: {
item: ItemT
index: number
renderItem:
| null
| undefined
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any
}): React.ReactNode {
if (!renderItem) {
return null
}
return (
<View style={styles.row}>
{renderItem({item, index, separators: null as any})}
</View>
)
}
Row = React.memo(Row)
let Visibility = ({
topMargin = '0px',
onVisibleChange,
style,
}: {
topMargin?: string
onVisibleChange: (isVisible: boolean) => void
style?: ViewProps['style']
}): React.ReactNode => {
const tailRef = React.useRef(null)
const isIntersecting = React.useRef(false)
const handleIntersection = useNonReactiveCallback(
(entries: IntersectionObserverEntry[]) => {
batchedUpdates(() => {
entries.forEach(entry => {
if (entry.isIntersecting !== isIntersecting.current) {
isIntersecting.current = entry.isIntersecting
onVisibleChange(entry.isIntersecting)
}
})
})
},
)
React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
rootMargin: `${topMargin} 0px 0px 0px`,
})
const tail: Element | null = tailRef.current!
observer.observe(tail)
return () => {
observer.unobserve(tail)
}
}, [handleIntersection, topMargin])
return (
<View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
)
}
Visibility = React.memo(Visibility)
export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
) => React.ReactElement
const styles = StyleSheet.create({
contentContainer: {
borderLeftWidth: 1,
borderRightWidth: 1,
},
containerScroll: {
width: '100%',
maxWidth: 600,
marginLeft: 'auto',
marginRight: 'auto',
},
row: {
// @ts-ignore web only
contentVisibility: 'auto',
},
minHeightViewport: {
// @ts-ignore web only
minHeight: '100vh',
},
parentTreeVisibilityDetector: {
// @ts-ignore web only
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
aboveTheFoldDetector: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
// Bottom is dynamic.
},
visibilityDetector: {
pointerEvents: 'none',
zIndex: -1,
},
})

View File

@ -1,9 +1,10 @@
import React, {useCallback} from 'react'
import React, {useCallback, useEffect} from 'react'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative} from 'platform/detection'
import {isNative, isWeb} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated'
const WEB_HIDE_SHELL_THRESHOLD = 200
@ -20,6 +21,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
useEffect(() => {
if (isWeb) {
return listenToForcedWindowScroll(() => {
startDragOffset.value = null
startMode.value = null
})
}
})
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
@ -100,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
</ScrollProvider>
)
}
const emitter = new EventEmitter()
if (isWeb) {
const originalScroll = window.scroll
window.scroll = function () {
emitter.emit('forced-scroll')
return originalScroll.apply(this, arguments as any)
}
const originalScrollTo = window.scrollTo
window.scrollTo = function () {
emitter.emit('forced-scroll')
return originalScrollTo.apply(this, arguments as any)
}
}
function listenToForcedWindowScroll(listener: () => void) {
emitter.addListener('forced-scroll', listener)
return () => {
emitter.removeListener('forced-scroll', listener)
}
}

View File

@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from 'lib/analytics/analytics'
import {NavigationProp} from 'lib/routes/types'
import {useSetDrawerOpen} from '#/state/shell'
import {isWeb} from '#/platform/detection'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@ -47,7 +48,14 @@ export function SimpleViewHeader({
const Container = isMobile ? View : CenteredView
return (
<Container style={[styles.header, isMobile && styles.headerMobile, style]}>
<Container
style={[
styles.header,
isMobile && styles.headerMobile,
isWeb && styles.headerWeb,
pal.view,
style,
]}>
{showBackButton ? (
<TouchableOpacity
testID="viewHeaderDrawerBtn"
@ -89,6 +97,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 10,
},
headerWeb: {
// @ts-ignore web-only
position: 'sticky',
top: 0,
zIndex: 1,
},
backBtn: {
width: 30,
height: 30,

View File

@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
const styles = StyleSheet.create({
container: {
position: 'absolute',
// @ts-ignore web only
position: 'fixed',
left: 20,
bottom: 20,
// @ts-ignore web only

View File

@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {clamp} from 'lib/numbers'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {isWeb} from '#/platform/detection'
import Animated from 'react-native-reanimated'
export interface FABProps
@ -64,7 +65,8 @@ const styles = StyleSheet.create({
borderRadius: 35,
},
outer: {
position: 'absolute',
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
},
inner: {

View File

@ -25,6 +25,7 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
import {CenteredView} from '../com/util/Views'
import {useComposerControls} from '#/state/shell/composer'
import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
export function PostThreadScreen({route}: Props) {
@ -112,7 +113,8 @@ export function PostThreadScreen({route}: Props) {
const styles = StyleSheet.create({
prompt: {
position: 'absolute',
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},

View File

@ -334,7 +334,9 @@ export function SearchScreenInner({
tabBarPosition="top"
onPageSelected={onPageSelected}
renderTabBar={props => (
<CenteredView sideBorders style={pal.border}>
<CenteredView
sideBorders
style={[pal.border, pal.view, styles.tabBarContainer]}>
<TabBar items={SECTIONS_LOGGEDIN} {...props} />
</CenteredView>
)}
@ -375,7 +377,9 @@ export function SearchScreenInner({
tabBarPosition="top"
onPageSelected={onPageSelected}
renderTabBar={props => (
<CenteredView sideBorders style={pal.border}>
<CenteredView
sideBorders
style={[pal.border, pal.view, styles.tabBarContainer]}>
<TabBar items={SECTIONS_LOGGEDOUT} {...props} />
</CenteredView>
)}
@ -466,6 +470,7 @@ export function SearchScreen(
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setQuery('')
setShowAutocompleteResults(false)
@ -473,11 +478,13 @@ export function SearchScreen(
clearTimeout(searchDebounceTimeout.current)
}, [textInput])
const onPressClearQuery = React.useCallback(() => {
scrollToTopWeb()
setQuery('')
setShowAutocompleteResults(false)
}, [setQuery])
const onChangeText = React.useCallback(
async (text: string) => {
scrollToTopWeb()
setQuery(text)
if (text.length > 0) {
@ -506,10 +513,12 @@ export function SearchScreen(
[setQuery, search, setSearchResults],
)
const onSubmit = React.useCallback(() => {
scrollToTopWeb()
setShowAutocompleteResults(false)
}, [setShowAutocompleteResults])
const onSoftReset = React.useCallback(() => {
scrollToTopWeb()
onPressCancelSearch()
}, [onPressCancelSearch])
@ -526,11 +535,12 @@ export function SearchScreen(
)
return (
<View style={{flex: 1}}>
<View style={isWeb ? null : {flex: 1}}>
<CenteredView
style={[
styles.header,
pal.border,
pal.view,
isTabletOrDesktop && {paddingTop: 10},
]}
sideBorders={isTabletOrDesktop}>
@ -661,12 +671,25 @@ export function SearchScreen(
)
}
function scrollToTopWeb() {
if (isWeb) {
window.scrollTo(0, 0)
}
}
const HEADER_HEIGHT = 50
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
height: HEADER_HEIGHT,
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: 0,
zIndex: 1,
},
headerMenuBtn: {
width: 30,
@ -696,4 +719,10 @@ const styles = StyleSheet.create({
headerCancelBtn: {
paddingLeft: 10,
},
tabBarContainer: {
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: isWeb ? HEADER_HEIGHT : 0,
zIndex: 1,
},
})

View File

@ -5,6 +5,7 @@ import {ComposePost} from '../com/composer/Composer'
import {useComposerState} from 'state/shell/composer'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {
EmojiPicker,
EmojiPickerState,
@ -16,6 +17,8 @@ export function Composer({}: {winHeight: number}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const state = useComposerState()
const isActive = !!state
useWebBodyScrollLock(isActive)
const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
isOpen: false,
@ -40,7 +43,7 @@ export function Composer({}: {winHeight: number}) {
// rendering
// =
if (!state) {
if (!isActive) {
return <View />
}
@ -75,7 +78,8 @@ export function Composer({}: {winHeight: number}) {
const styles = StyleSheet.create({
mask: {
position: 'absolute',
// @ts-ignore
position: 'fixed',
top: 0,
left: 0,
width: '100%',

View File

@ -12,6 +12,10 @@ export const styles = StyleSheet.create({
paddingLeft: 5,
paddingRight: 10,
},
bottomBarWeb: {
// @ts-ignore web-only
position: 'fixed',
},
ctrl: {
flex: 1,
paddingTop: 13,

View File

@ -57,6 +57,7 @@ export function BottomBarWeb() {
<Animated.View
style={[
styles.bottomBar,
styles.bottomBarWeb,
pal.view,
pal.border,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},

View File

@ -442,10 +442,11 @@ export function DesktopLeftNav() {
const styles = StyleSheet.create({
leftNav: {
position: 'absolute',
// @ts-ignore web only
position: 'fixed',
top: 10,
// @ts-ignore web only
right: 'calc(50vw + 312px)',
left: 'calc(50vw - 300px - 220px - 20px)',
width: 220,
// @ts-ignore web only
maxHeight: 'calc(100vh - 10px)',

View File

@ -177,9 +177,10 @@ function InviteCodes() {
const styles = StyleSheet.create({
rightNav: {
position: 'absolute',
// @ts-ignore web only
left: 'calc(50vw + 320px)',
position: 'fixed',
// @ts-ignore web only
left: 'calc(50vw + 300px + 20px)',
width: 300,
maxHeight: '100%',
overflowY: 'auto',

View File

@ -15,6 +15,7 @@ import {useAuxClick} from 'lib/hooks/useAuxClick'
import {t} from '@lingui/macro'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useCloseAllActiveElements} from '#/state/util'
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {Outlet as PortalOutlet} from '#/components/Portal'
function ShellInner() {
@ -24,6 +25,7 @@ function ShellInner() {
const navigator = useNavigation<NavigationProp>()
const closeAllActiveElements = useCloseAllActiveElements()
useWebBodyScrollLock(isDrawerOpen)
useAuxClick()
useEffect(() => {
@ -34,12 +36,10 @@ function ShellInner() {
}, [navigator, closeAllActiveElements])
return (
<View style={[s.hContentRegion, {overflow: 'hidden'}]}>
<View style={s.hContentRegion}>
<>
<ErrorBoundary>
<FlatNavigator />
</ErrorBoundary>
</View>
<Composer winHeight={0} />
<ModalsContainer />
<PortalOutlet />
@ -55,7 +55,7 @@ function ShellInner() {
</View>
</TouchableOpacity>
)}
</View>
</>
)
}
@ -78,7 +78,8 @@ const styles = StyleSheet.create({
backgroundColor: colors.black, // TODO
},
drawerMask: {
position: 'absolute',
// @ts-ignore web only
position: 'fixed',
width: '100%',
height: '100%',
top: 0,
@ -87,7 +88,8 @@ const styles = StyleSheet.create({
},
drawerContainer: {
display: 'flex',
position: 'absolute',
// @ts-ignore web only
position: 'fixed',
top: 0,
left: 0,
height: '100%',

View File

@ -37,10 +37,10 @@
}
html {
scroll-behavior: smooth;
/* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
-webkit-text-size-adjust: 100%;
height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable;
}
/* Remove autofill styles on Webkit */

View File

@ -7525,6 +7525,13 @@
dependencies:
"@types/react" "*"
"@types/react-dom@^18.2.18":
version "18.2.18"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==
dependencies:
"@types/react" "*"
"@types/react-responsive@^8.0.5":
version "8.0.5"
resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.5.tgz#77769862d2a0711434feb972be08e3e6c334440a"