Compare commits

...

20 Commits

Author SHA1 Message Date
Eric Bailey 20a5ac8cd2 Revert "Swap in new list for feeds"
This reverts commit b71575a3f0.
2025-09-24 11:12:00 -05:00
Eric Bailey e8f830aad7 Revert "Hackfix PostFeed, TODO"
This reverts commit e7a4686bc4.
2025-09-24 11:11:38 -05:00
Eric Bailey 94d9357b69 Make virtualization more apparent in storybook 2025-09-24 11:11:03 -05:00
Eric Bailey 391a1d4be3 Checkpoint Layout.List 2025-09-24 11:05:01 -05:00
Eric Bailey e44a022226 Add web sticky header support 2025-09-24 10:57:27 -05:00
Eric Bailey 4b19e3794e Checkpoint external sticky header 2025-09-24 09:48:51 -05:00
Eric Bailey e7a4686bc4 Hackfix PostFeed, TODO 2025-09-24 09:48:16 -05:00
Eric Bailey b71575a3f0 Swap in new list for feeds 2025-09-23 15:38:49 -05:00
Eric Bailey 2b10180d74 Update Storybook ListContained to use new List 2025-09-23 15:38:48 -05:00
Eric Bailey 9a73611f73 Update types with better docs, add active video handling 2025-09-23 15:37:29 -05:00
Eric Bailey 2b6cff298a Use ScrollProvider for old list 2025-09-23 15:37:29 -05:00
Eric Bailey afa6d67c23 Add toggle to switch between list impl, add default keyExtractor 2025-09-23 15:37:29 -05:00
Eric Bailey d53c0e548f Add onScrolledDownChange 2025-09-23 15:37:29 -05:00
Eric Bailey ca5d4bff0c Add RefreshControl 2025-09-23 15:37:29 -05:00
Eric Bailey a8b78588fb Add lightbox handling for menu bar taps 2025-09-23 15:37:29 -05:00
Eric Bailey 0098d617ee Add header/footer offset 2025-09-23 15:37:29 -05:00
Eric Bailey 661d20d176 Add onItemSeen and automatic insets default 2025-09-23 15:37:28 -05:00
Eric Bailey c85ce941c3 Add initial impl, scroll context 2025-09-23 15:37:28 -05:00
Eric Bailey 2ad93a5895 Add lists storybook screen 2025-09-23 15:37:28 -05:00
Eric Bailey ffac99ff80 Add dev shortcut to Storybook 2025-09-23 15:37:28 -05:00
11 changed files with 482 additions and 11 deletions
+6
View File
@@ -65,6 +65,7 @@ import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {ProfileScreen} from '#/view/screens/Profile'
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {Storybook} from '#/view/screens/Storybook'
import {StorybookLists} from '#/view/screens/StorybookLists'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
@@ -305,6 +306,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => Storybook}
options={{title: title(msg`Storybook`), requireAuth: true}}
/>
<Stack.Screen
name="StorybookLists"
getComponent={() => StorybookLists}
options={{title: title(msg`Storybook Lists`), requireAuth: true}}
/>
<Stack.Screen
name="DebugMod"
getComponent={() => DebugModScreen}
+17
View File
@@ -23,6 +23,11 @@ import {
import {useDialogContext} from '#/components/Dialog'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {
List as BaseList,
type ListItem,
type ListProps,
} from '#/components/List'
export * from '#/components/Layout/const'
export * as Header from '#/components/Layout/Header'
@@ -223,3 +228,15 @@ const WebCenterBorders = memo(function LayoutWebCenterBorders() {
/>
) : null
})
export function List<Item extends ListItem>(props: ListProps<Item>) {
return (
<BaseList<Item>
{...props}
renderItem={item => {
return <Center>{props.renderItem!(item)}</Center>
}}
style={[a.h_full_vh, props.style]}
/>
)
}
@@ -0,0 +1,72 @@
import {createContext, useCallback, useContext, useMemo} from 'react'
import {type NativeScrollEvent} from 'react-native'
import {type ScrollHandlers} from 'react-native-reanimated'
export type NormalizedScrollHandlers = {
/**
* Web + Native — needs to be a `worklet` on native, but can be either on web
*/
onScroll?: ScrollHandlers<any>['onScroll']
/**
* Native only
*/
onScrollBeginDrag?: ScrollHandlers<any>['onBeginDrag']
/**
* Native only
*/
onScrollEndDrag?: ScrollHandlers<any>['onEndDrag']
/**
* Native only
*/
onMomentumScrollBegin?: ScrollHandlers<any>['onMomentumBegin']
/**
* Native only
*/
onMomentumScrollEnd?: ScrollHandlers<any>['onMomentumEnd']
}
const ListScrollContext = createContext<NormalizedScrollHandlers>({
onScroll: undefined,
onScrollBeginDrag: undefined,
onScrollEndDrag: undefined,
onMomentumScrollBegin: undefined,
onMomentumScrollEnd: undefined,
})
ListScrollContext.displayName = 'ListScrollContext'
export function ListScrollProvider({
children,
onScrollBeginDrag,
onScrollEndDrag,
onScroll,
onMomentumScrollBegin,
onMomentumScrollEnd,
}: {children: React.ReactNode} & NormalizedScrollHandlers) {
const handlers = useMemo(
() => ({
onScroll,
onScrollBeginDrag: onScrollBeginDrag,
onScrollEndDrag: onScrollEndDrag,
onMomentumScrollBegin: onMomentumScrollBegin,
onMomentumScrollEnd: onMomentumScrollEnd,
}),
[
onScrollBeginDrag,
onScrollEndDrag,
onScroll,
onMomentumScrollBegin,
onMomentumScrollEnd,
],
)
return (
<ListScrollContext.Provider value={handlers}>
{children}
</ListScrollContext.Provider>
)
}
export function useListScrollContext(): NormalizedScrollHandlers {
return useContext(ListScrollContext)
}
export const useListScrollHandler = useCallback<(e: NativeScrollEvent) => void>
+235
View File
@@ -0,0 +1,235 @@
import {forwardRef, useMemo} from 'react'
import {
type FlatList,
type FlatListProps,
RefreshControl,
type ViewToken,
} from 'react-native'
import Animated, {
type FlatListPropsWithLayout,
runOnJS,
useAnimatedScrollHandler,
useSharedValue,
} from 'react-native-reanimated'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {isIOS, isNative} from '#/platform/detection'
import {useLightbox} from '#/state/lightbox'
import {atoms as a, useTheme} from '#/alf'
import {useListScrollContext} from '#/components/List/ListScrollProvider'
export {
ListScrollProvider,
useListScrollHandler,
} from '#/components/List/ListScrollProvider'
export type ListRef<Item extends {key: string}> =
React.MutableRefObject<FlatList<Item> | null>
export type ListItem = {key: string}
export type ListProps<Item extends ListItem> = Omit<
FlatListProps<Item>,
| 'onScroll'
| 'onScrollBeginDrag'
| 'onScrollEndDrag'
| 'onMomentumScrollBegin'
| 'onMomentumScrollEnd'
| 'refreshControl'
| 'contentOffset'
> & {
/**
* @deprecated use `ListScrollProvider` handler instead
*/
onScroll?: FlatListProps<Item>['onScroll']
/**
* @deprecated use `ListScrollProvider` handler instead
*/
onScrollBeginDrag?: FlatListProps<Item>['onScrollBeginDrag']
/**
* @deprecated use `ListScrollProvider` handler instead
*/
onScrollEndDrag?: FlatListProps<Item>['onScrollEndDrag']
/**
* @deprecated use `ListScrollProvider` handler instead
*/
onMomentumScrollBegin?: FlatListProps<Item>['onMomentumScrollBegin']
/**
* @deprecated use `ListScrollProvider` handler instead
*/
onMomentumScrollEnd?: FlatListProps<Item>['onMomentumScrollEnd']
/**
* @deprecated pass `refreshing` and `onRefresh` instead to enable
*/
refreshControl?: FlatListProps<Item>['refreshControl']
/**
* @deprecated use `headerOffset` instead
*/
contentOffset?: FlatListProps<Item>['contentOffset']
/**
* Wrapper around `onViewableItemsChanged` that calls back with individual
* items IF they `item.isViewable` is true.
*/
onItemSeen?: (item: Item) => void
/**
* Sugar for adding padding to the top of the list to accommodate fixed
* headers. Also applies insets to the scroll indicators.
*/
headerOffset?: number
/**
* Sugar for adding padding to the bottom of the list to accommodate fixed
* footers. Also applies insets to the scroll indicators.
*/
footerOffset?: number
/**
* Configures the point at which `onScrolledDownChange` is called.
*/
didScrollDownThreshold?: number
onScrolledDownChange?: (isScrolledDown: boolean) => void
}
export const List = forwardRef(function List<Item extends ListItem>(
{...props}: ListProps<Item>,
ref: React.Ref<FlatList<Item>>,
) {
const t = useTheme()
const {activeLightbox} = useLightbox()
const debounce400 = useDedupe(400)
const isScrolledDown = useSharedValue(false)
const scrollHandlers = useListScrollContext()
const onScroll = useAnimatedScrollHandler({
onScroll(e, ctx) {
scrollHandlers.onScroll?.(e, ctx)
const didScrollDown =
e.contentOffset.y > (props.didScrollDownThreshold ?? 200)
if (isScrolledDown.get() !== didScrollDown) {
isScrolledDown.set(didScrollDown)
if (props.onScrolledDownChange) {
runOnJS(props.onScrolledDownChange)(didScrollDown)
}
}
if (isIOS) runOnJS(debounce400)(updateActiveVideoViewAsync)
},
onBeginDrag(e, ctx) {
scrollHandlers.onScrollBeginDrag?.(e, ctx)
},
onEndDrag(e, ctx) {
scrollHandlers.onScrollEndDrag?.(e, ctx)
if (isNative) runOnJS(updateActiveVideoViewAsync)()
},
/*
* Note: adding onMomentumBegin here makes simulator scroll lag on Android.
* So either don't add it, or figure out why. - sfn
* TODO
*/
onMomentumBegin(e, ctx) {
scrollHandlers.onMomentumScrollBegin?.(e, ctx)
},
onMomentumEnd(e, ctx) {
scrollHandlers.onMomentumScrollEnd?.(e, ctx)
if (isNative) runOnJS(updateActiveVideoViewAsync)()
},
})
const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
const onItemSeen = props.onItemSeen
if (!onItemSeen) return [undefined, undefined]
return [
(info: {
viewableItems: Array<ViewToken<Item>>
changed: Array<ViewToken<Item>>
}) => {
for (const item of info.changed) {
if (item.isViewable) {
onItemSeen(item.item)
}
}
},
{
itemVisiblePercentThreshold: 40,
minimumViewTime: 0.5e3,
},
]
}, [props.onItemSeen])
let refreshControl
if (props.refreshing !== undefined || props.onRefresh !== undefined) {
refreshControl = (
<RefreshControl
key={t.atoms.text.color}
refreshing={props.refreshing ?? false}
onRefresh={props.onRefresh ?? undefined}
tintColor={t.atoms.text.color}
titleColor={t.atoms.text.color}
progressViewOffset={props.progressViewOffset ?? props.headerOffset}
/>
)
}
/**
* Web only, provides a handle on the resulting `List` DOM element, which we
* then use to apply sticky styles to individual list item wrappers.
*/
const dataSet = props.stickyHeaderIndices?.reduce((ds, i) => {
return {...ds, [`sticky-header-index-${i}`]: 1}
}, {})
return (
<>
<Animated.FlatList
// @ts-ignore
dataSet={dataSet}
ref={ref}
keyExtractor={props.keyExtractor || (i => i.key)}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
/**
* iOS automatically adds in the safe area to the scroll indicator
* insets, even though the overwhelming majority of our ScrollViews do
* not stretch from edge to edge.
* @see https://github.com/bluesky-social/social-app/pull/7131
*/
automaticallyAdjustsScrollIndicatorInsets={false}
/**
* For better UX, we default to true, but it can be disabled if needed.
* @see https://github.com/bluesky-social/social-app/pull/8529
*/
showsVerticalScrollIndicator
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
scrollIndicatorInsets={{
top: props.headerOffset ?? 0,
bottom: props.footerOffset ?? 0,
/**
* May fix a bug where the scroll indicator is in the middle of the screen
* @see https://github.com/facebook/react-native/issues/26610
*/
right: 1,
}}
/**
* Native only. On web, we use padding on `style` instead.
*/
contentOffset={
props.headerOffset ? {x: 0, y: props.headerOffset * -1} : undefined
}
scrollsToTop={!activeLightbox}
refreshControl={refreshControl}
{...(props as FlatListPropsWithLayout<Item>)}
style={[
a.h_full,
{
paddingTop: props.headerOffset,
paddingBottom: props.footerOffset,
transform: 'unset',
},
props.style,
]}
onScroll={onScroll}
/>
</>
)
}) as <Item extends {key: string}>(
props: ListProps<Item> & {ref?: React.Ref<FlatList<Item>>},
) => React.ReactElement
+1
View File
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
ProfileLabelerLikedBy: {name: string}
Debug: undefined
DebugMod: undefined
StorybookLists: undefined
SharedPreferencesTester: undefined
Log: undefined
Support: undefined
+1
View File
@@ -39,6 +39,7 @@ export const router = new Router<AllNavigatableRoutes>({
// debug
Debug: '/sys/debug',
DebugMod: '/sys/debug-mod',
StorybookLists: '/sys/debug-lists',
Log: '/sys/log',
// settings
LanguageSettings: '/settings/language',
+22
View File
@@ -389,3 +389,25 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
opacity: 0;
}
}
/*
* Sticky headers for `List` component. Add more indices as needed.
*
* Note — :nth-child is 1-indexed
*/
*[data-sticky-header-index-0] > div > div:nth-child(1),
*[data-sticky-header-index-1] > div > div:nth-child(2),
*[data-sticky-header-index-2] > div > div:nth-child(3),
*[data-sticky-header-index-3] > div > div:nth-child(4),
*[data-sticky-header-index-4] > div > div:nth-child(5),
*[data-sticky-header-index-5] > div > div:nth-child(6),
*[data-sticky-header-index-6] > div > div:nth-child(7),
*[data-sticky-header-index-7] > div > div:nth-child(8),
*[data-sticky-header-index-8] > div > div:nth-child(9),
*[data-sticky-header-index-9] > div > div:nth-child(10) {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
+9 -1
View File
@@ -3,11 +3,13 @@ import {View} from 'react-native'
import Animated from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform'
import {type NavigationProp} from '#/lib/routes/types'
import {emitSoftReset} from '#/state/events'
import {useSession} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
@@ -17,6 +19,7 @@ import {ButtonIcon} from '#/components/Button'
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {IS_DEV} from '#/env'
export function HomeHeaderLayoutMobile({
children,
@@ -30,6 +33,7 @@ export function HomeHeaderLayoutMobile({
const headerMinimalShellTransform = useMinimalShellHeaderTransform()
const {hasSession} = useSession()
const playHaptic = useHaptics()
const navigation = useNavigation<NavigationProp>()
return (
<Animated.View
@@ -57,7 +61,11 @@ export function HomeHeaderLayoutMobile({
targetScale={0.9}
onPress={() => {
playHaptic('Light')
emitSoftReset()
if (IS_DEV) {
navigation.navigate('Debug')
} else {
emitSoftReset()
}
}}>
<Logo width={30} />
</PressableScale>
+9 -10
View File
@@ -1,19 +1,18 @@
import React from 'react'
import {View} from 'react-native'
import {type FlatList, View} from 'react-native'
import {ScrollProvider} from '#/lib/ScrollContext'
import {List, type ListMethods} from '#/view/com/util/List'
import {Button, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {List, ListScrollProvider} from '#/components/List'
import {Text} from '#/components/Typography'
export function ListContained() {
const [animated, setAnimated] = React.useState(false)
const ref = React.useRef<ListMethods>(null)
const ref = React.useRef<FlatList>(null)
const data = React.useMemo(() => {
return Array.from({length: 100}, (_, i) => ({
id: i,
key: i + '',
text: `Message ${i}`,
}))
}, [])
@@ -21,7 +20,7 @@ export function ListContained() {
return (
<>
<View style={{width: '100%', height: 300}}>
<ScrollProvider
<ListScrollProvider
onScroll={e => {
'worklet'
console.log(
@@ -33,6 +32,7 @@ export function ListContained() {
)
}}>
<List
ref={ref}
data={data}
renderItem={item => {
return (
@@ -46,8 +46,8 @@ export function ListContained() {
</View>
)
}}
keyExtractor={item => item.id.toString()}
disableFullWindowScroll={true}
keyExtractor={item => item.key.toString()}
// disableFullWindowScroll={true}
style={{flex: 1}}
onStartReached={() => {
console.log('Start Reached')
@@ -56,10 +56,9 @@ export function ListContained() {
console.log('End Reached (threshold of 2)')
}}
onEndReachedThreshold={2}
ref={ref}
disableVirtualization={true}
/>
</ScrollProvider>
</ListScrollProvider>
</View>
<View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}>
+9
View File
@@ -8,6 +8,7 @@ import {ListContained} from '#/view/screens/Storybook/ListContained'
import {atoms as a, ThemeProvider} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {Admonitions} from './Admonitions'
import {Breakpoints} from './Breakpoints'
import {Buttons} from './Buttons'
@@ -96,6 +97,14 @@ function StorybookInner() {
<ButtonText>Open Shared Prefs Tester</ButtonText>
</Button>
<Link
to="/sys/debug-lists"
label="Lists debugger"
size="small"
color="primary_subtle">
<ButtonText>Lists debugger</ButtonText>
</Link>
<ThemeProvider theme="light">
<Theming />
</ThemeProvider>
+101
View File
@@ -0,0 +1,101 @@
import {View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import {atoms as a, useTheme} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListScrollProvider, useListScrollHandler} from '#/components/List'
import {Text} from '#/components/Typography'
export function StorybookLists() {
return (
<Layout.Screen>
<Inner />
</Layout.Screen>
)
}
type Item =
| {
key: string
type: 'item'
title: string
}
| {
key: string
type: 'header'
title: string
}
| {
key: string
type: 'spacer'
}
const log = (msg: any) => console.log(msg)
export function Inner() {
const t = useTheme()
const onScrollWorklet = useListScrollHandler(e => {
'worklet'
runOnJS(log)(`scroll ${e.contentOffset.y}`)
}, [])
const items: Item[] = Array.from({length: 1000}).map((_, i) => ({
key: `item-${i + 1}`,
type: 'item' as const,
title: `Item ${i + 1}`,
}))
items.unshift({
key: 'header',
type: 'header' as const,
title: 'Header',
})
items.unshift({
key: 'spacer',
type: 'spacer' as const,
})
return (
<ListScrollProvider onScroll={onScrollWorklet}>
<Layout.List<Item>
windowSize={9}
maxToRenderPerBatch={5}
data={items}
stickyHeaderIndices={[1]}
renderItem={({item, index}) => {
if (item.type === 'header') {
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>Storybook</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
)
}
if (item.type === 'spacer') {
return <View style={[t.atoms.bg_contrast_50, {height: 100}]} />
}
return (
<View
style={[
a.px_md,
a.align_center,
a.justify_center,
{height: 100},
index % 2 === 0 ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<Text>{item.title}</Text>
</View>
)
}}
onScrolledDownChange={scrolledDown => {
console.log(`Scrolled down: ${scrolledDown}`)
}}
/>
</ListScrollProvider>
)
}