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 * twkzio/stable
parent
cd02922b03
commit
f015229acf
|
@ -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 */
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
// @ts-ignore
|
||||
export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export function useWebScrollRestoration() {
|
||||
return undefined
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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%',
|
||||
|
|
|
@ -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%',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) => {
|
||||
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: <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>
|
||||
))}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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) => {
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
onScrolledDownChange?.(didScrollDown)
|
||||
}
|
||||
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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%',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -57,6 +57,7 @@ export function BottomBarWeb() {
|
|||
<Animated.View
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
pal.view,
|
||||
pal.border,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
|
||||
|
|
|
@ -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)',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<FlatNavigator />
|
||||
</ErrorBoundary>
|
||||
<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%',
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue