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 ( + + + ) +} + +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"