diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 50dbaa24..228c3d89 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -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 */
diff --git a/package.json b/package.json
index 40e2a19f..17677fb9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 3689cfc9..35d8dff7 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -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 (
{
+ if (!isWeb || !isLockActive) {
+ return
+ }
+ incrementRefCount()
+ return () => decrementRefCount()
+ })
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.native.ts b/src/lib/hooks/useWebScrollRestoration.native.ts
new file mode 100644
index 00000000..c7d96607
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.native.ts
@@ -0,0 +1,3 @@
+export function useWebScrollRestoration() {
+ return undefined
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts
new file mode 100644
index 00000000..f68fbf0f
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.ts
@@ -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
+}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 5a10fea8..df9b4926 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -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,
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 6d16403f..14936211 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -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,
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 49f28098..9595e77e 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -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
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index a258d25a..fb97c30a 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -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({
@@ -129,7 +136,7 @@ function LightboxInner({
accessibilityHint="">
@@ -143,7 +150,7 @@ function LightboxInner({
accessibilityHint="">
@@ -178,7 +185,8 @@ function LightboxInner({
const styles = StyleSheet.create({
mask: {
- position: 'absolute',
+ // @ts-ignore
+ position: 'fixed',
top: 0,
left: 0,
width: '100%',
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index e11e76fc..d7966374 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -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%',
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 57c83f17..385da554 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -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
{
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,
},
})
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 9c562f67..b9959a6d 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -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,
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 61c3609f..834b1c0d 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -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
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 3b5e9164..dde799e4 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -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>([])
+ 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) => {
- setSelectedPage(index)
- onPageSelected?.(index)
- onPageSelecting?.(index)
+ 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: ,
onSelect: onTabBarSelect,
})}
{React.Children.map(children, (child, i) => (
-
+
{child}
))}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 158940d6..279b607a 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -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 (
+ style={[styles.tabBarMobile, headerTransform]}>
{renderHeader?.()}
@@ -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() {
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
new file mode 100644
index 00000000..0a18a9e7
--- /dev/null
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -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 | 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(
+ function PageWithHeaderImpl(
+ {
+ children,
+ testID,
+ items,
+ renderHeader,
+ initialPage,
+ onPageSelected,
+ onCurrentPageSelected,
+ }: PagerWithHeaderProps,
+ ref,
+ ) {
+ const [currentPage, setCurrentPage] = React.useState(0)
+
+ const renderTabBar = React.useCallback(
+ (props: RenderTabBarFnProps) => {
+ return (
+
+ )
+ },
+ [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 (
+
+ {toArray(children)
+ .filter(Boolean)
+ .map((child, i) => {
+ return (
+
+
+
+ )
+ })}
+
+ )
+ },
+)
+
+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 (
+ <>
+
+ {renderHeader?.()}
+
+ {tabBarAnchor}
+
+
+
+ >
+ )
+}
+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(v: T | T[]): T[] {
+ if (Array.isArray(v)) {
+ return v
+ }
+ return [v]
+}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index cb7fd3f4..49086652 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -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(null)
const highlightedPostRef = useRef(null)
const needsScrollAdjustment = useRef(
@@ -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) => {
- ref.current?.scrollToOffset({
- animated: false,
- offset: pageY - (isDesktop ? 0 : 50),
- })
- },
- )
+ function onMeasure(pageY: number) {
+ let spinnerHeight = 0
+ if (isDesktop) {
+ spinnerHeight = 40
+ } else if (isTabletOrMobile) {
+ spinnerHeight = 82
+ }
+ ref.current?.scrollToOffset({
+ animated: false,
+ 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)
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 9abd7d35..d30a9d80 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -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(
const pal = usePalette('default')
function handleScrolledDownChange(didScrollDown: boolean) {
- startTransition(() => {
- onScrolledDownChange?.(didScrollDown)
- })
+ onScrolledDownChange?.(didScrollDown)
}
const scrollHandler = useAnimatedScrollHandler({
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
new file mode 100644
index 00000000..3e81a8c3
--- /dev/null
+++ b/src/view/com/util/List.web.tsx
@@ -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 = Omit<
+ FlatListProps,
+ | '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 // TODO: Better types.
+
+function ListImpl(
+ {
+ ListHeaderComponent,
+ ListFooterComponent,
+ contentContainerStyle,
+ data,
+ desktopFixedHeight,
+ headerOffset,
+ keyExtractor,
+ refreshing: _unsupportedRefreshing,
+ onEndReached,
+ onEndReachedThreshold = 0,
+ onRefresh: _unsupportedOnRefresh,
+ onScrolledDownChange,
+ onContentSizeChange,
+ renderItem,
+ extraData,
+ style,
+ ...props
+ }: ListProps,
+ ref: React.Ref,
+) {
+ 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 =
+ }
+ }
+
+ let footer: JSX.Element | null = null
+ if (ListFooterComponent != null) {
+ if (isValidElement(ListFooterComponent)) {
+ footer = ListFooterComponent
+ } else {
+ // @ts-ignore Nah it's fine.
+ footer =
+ }
+ }
+
+ 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 (
+
+
+
+
+ {header}
+ {(data as Array).map((item, index) => (
+
+ key={keyExtractor!(item, index)}
+ item={item}
+ index={index}
+ renderItem={renderItem}
+ extraData={extraData}
+ />
+ ))}
+ {onEndReached && (
+
+ )}
+ {footer}
+
+
+ )
+}
+
+function useResizeObserver(
+ ref: React.RefObject,
+ 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({
+ 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 (
+
+ {renderItem({item, index, separators: null as any})}
+
+ )
+}
+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 (
+
+ )
+}
+Visibility = React.memo(Visibility)
+
+export const List = memo(React.forwardRef(ListImpl)) as (
+ props: ListProps & {ref?: React.Ref},
+) => 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,
+ },
+})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 3ac28d31..2c90e33f 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -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(null)
const startMode = useSharedValue(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}) {
)
}
+
+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)
+ }
+}
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
index e86e3756..814b2fb1 100644
--- a/src/view/com/util/SimpleViewHeader.tsx
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -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 (
-
+
{showBackButton ? (
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,
},
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 94aab2d9..bfa8e1b2 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -334,7 +334,9 @@ export function SearchScreenInner({
tabBarPosition="top"
onPageSelected={onPageSelected}
renderTabBar={props => (
-
+
)}
@@ -375,7 +377,9 @@ export function SearchScreenInner({
tabBarPosition="top"
onPageSelected={onPageSelected}
renderTabBar={props => (
-
+
)}
@@ -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 (
-
+
@@ -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,
+ },
})
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index ed64bc79..99e659d6 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -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({
isOpen: false,
@@ -40,7 +43,7 @@ export function Composer({}: {winHeight: number}) {
// rendering
// =
- if (!state) {
+ if (!isActive) {
return
}
@@ -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%',
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index ae938144..f226406f 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -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,
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index c5dc376b..b330c4b8 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -57,6 +57,7 @@ export function BottomBarWeb() {
()
const closeAllActiveElements = useCloseAllActiveElements()
+ useWebBodyScrollLock(isDrawerOpen)
useAuxClick()
useEffect(() => {
@@ -34,12 +36,10 @@ function ShellInner() {
}, [navigator, closeAllActiveElements])
return (
-
-
-
-
-
-
+ <>
+
+
+
@@ -55,7 +55,7 @@ function ShellInner() {
)}
-
+ >
)
}
@@ -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%',
diff --git a/web/index.html b/web/index.html
index a82abea9..92001e71 100644
--- a/web/index.html
+++ b/web/index.html
@@ -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 */
diff --git a/yarn.lock b/yarn.lock
index 3a775693..3e6ae4cf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"