forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {forwardRef, memo, useDeferredValue, useMemo} from 'react'
2import {RefreshControl, type ViewToken} from 'react-native'
3import {
4 type FlatListPropsWithLayout,
5 runOnJS,
6 useAnimatedScrollHandler,
7 useSharedValue,
8} from 'react-native-reanimated'
9import {updateActiveVideoViewAsync} from '@bsky.app/video'
10
11import {useDedupe} from '#/lib/hooks/useDedupe'
12import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
13import {useScrollHandlers} from '#/lib/ScrollContext'
14import {addStyle} from '#/lib/styles'
15import {useLightbox} from '#/state/lightbox'
16import {useTheme} from '#/alf'
17import {IS_IOS} from '#/env'
18import {FlatList_INTERNAL} from './Views'
19
20export type ListMethods = FlatList_INTERNAL
21export type ListProps<ItemT = any> = Omit<
22 FlatListPropsWithLayout<ItemT>,
23 | 'onMomentumScrollBegin' // Use ScrollContext instead.
24 | 'onMomentumScrollEnd' // Use ScrollContext instead.
25 | 'onScroll' // Use ScrollContext instead.
26 | 'onScrollBeginDrag' // Use ScrollContext instead.
27 | 'onScrollEndDrag' // Use ScrollContext instead.
28 | 'refreshControl' // Pass refreshing and/or onRefresh instead.
29 | 'contentOffset' // Pass headerOffset instead.
30 | 'progressViewOffset' // Can't be an animated value
31> & {
32 onScrolledDownChange?: (isScrolledDown: boolean) => void
33 onScrollOffsetChange?: (offsetY: number) => void
34 headerOffset?: number
35 refreshing?: boolean
36 onRefresh?: () => void
37 onItemSeen?: (item: ItemT) => void
38 desktopFixedHeight?: number | boolean
39 // Web only prop to contain the scroll to the container rather than the window
40 disableFullWindowScroll?: boolean
41 sideBorders?: boolean
42 progressViewOffset?: number
43}
44export type ListRef = React.RefObject<FlatList_INTERNAL | null>
45
46const SCROLLED_DOWN_LIMIT = 200
47
48let List = forwardRef<ListMethods, ListProps>(
49 (
50 {
51 onScrolledDownChange,
52 onScrollOffsetChange,
53 refreshing,
54 onRefresh,
55 onItemSeen,
56 headerOffset,
57 style,
58 progressViewOffset,
59 automaticallyAdjustsScrollIndicatorInsets = false,
60 ...props
61 },
62 ref,
63 ): React.ReactElement<any> => {
64 const isScrolledDown = useSharedValue(false)
65 const t = useTheme()
66 const dedupe = useDedupe(400)
67 const scrollsToTop = useAllowScrollToTop()
68
69 const handleScrolledDownChange = useNonReactiveCallback(
70 (didScrollDown: boolean) => {
71 onScrolledDownChange?.(didScrollDown)
72 },
73 )
74 const handleScrollOffsetChange = useNonReactiveCallback(
75 (offsetY: number) => {
76 onScrollOffsetChange?.(offsetY)
77 },
78 )
79
80 // Intentionally destructured outside the main thread closure.
81 // See https://github.com/bluesky-social/social-app/pull/4108.
82 const {
83 onBeginDrag: onBeginDragFromContext,
84 onEndDrag: onEndDragFromContext,
85 onScroll: onScrollFromContext,
86 onMomentumEnd: onMomentumEndFromContext,
87 } = useScrollHandlers()
88 const scrollHandler = useAnimatedScrollHandler({
89 onBeginDrag(e, ctx) {
90 onBeginDragFromContext?.(e, ctx)
91 },
92 onEndDrag(e, ctx) {
93 runOnJS(updateActiveVideoViewAsync)()
94 onEndDragFromContext?.(e, ctx)
95 },
96 onScroll(e, ctx) {
97 onScrollFromContext?.(e, ctx)
98
99 const didScrollDown = e.contentOffset.y > SCROLLED_DOWN_LIMIT
100 if (isScrolledDown.get() !== didScrollDown) {
101 isScrolledDown.set(didScrollDown)
102 if (onScrolledDownChange != null) {
103 runOnJS(handleScrolledDownChange)(didScrollDown)
104 }
105 }
106
107 if (onScrollOffsetChange != null) {
108 runOnJS(handleScrollOffsetChange)(e.contentOffset.y)
109 }
110
111 if (IS_IOS) {
112 runOnJS(dedupe)(updateActiveVideoViewAsync)
113 }
114 },
115 // Note: adding onMomentumBegin here makes simulator scroll
116 // lag on Android. So either don't add it, or figure out why.
117 onMomentumEnd(e, ctx) {
118 runOnJS(updateActiveVideoViewAsync)()
119 onMomentumEndFromContext?.(e, ctx)
120 },
121 })
122
123 const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
124 if (!onItemSeen) {
125 return [undefined, undefined]
126 }
127 return [
128 (info: {
129 viewableItems: Array<ViewToken>
130 changed: Array<ViewToken>
131 }) => {
132 for (const item of info.changed) {
133 if (item.isViewable) {
134 onItemSeen(item.item)
135 }
136 }
137 },
138 {
139 itemVisiblePercentThreshold: 40,
140 minimumViewTime: 0.5e3,
141 },
142 ]
143 }, [onItemSeen])
144
145 let refreshControl
146 if (refreshing !== undefined || onRefresh !== undefined) {
147 refreshControl = (
148 <RefreshControl
149 key={t.atoms.text.color}
150 refreshing={refreshing ?? false}
151 onRefresh={onRefresh}
152 tintColor={t.atoms.text.color}
153 titleColor={t.atoms.text.color}
154 progressViewOffset={progressViewOffset ?? headerOffset}
155 />
156 )
157 }
158
159 let contentOffset
160 if (headerOffset != null) {
161 style = addStyle(style, {
162 paddingTop: headerOffset,
163 })
164 contentOffset = {x: 0, y: headerOffset * -1}
165 }
166
167 return (
168 <FlatList_INTERNAL
169 showsVerticalScrollIndicator // overridable
170 onViewableItemsChanged={onViewableItemsChanged}
171 viewabilityConfig={viewabilityConfig}
172 {...props}
173 automaticallyAdjustsScrollIndicatorInsets={
174 automaticallyAdjustsScrollIndicatorInsets
175 }
176 scrollIndicatorInsets={{
177 top: headerOffset,
178 right: 1,
179 ...props.scrollIndicatorInsets,
180 }}
181 indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
182 contentOffset={contentOffset}
183 refreshControl={refreshControl}
184 onScroll={scrollHandler}
185 scrollsToTop={scrollsToTop}
186 scrollEventThrottle={1}
187 style={style}
188 // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
189 ref={ref}
190 />
191 )
192 },
193)
194List.displayName = 'List'
195
196List = memo(List)
197export {List}
198
199// We only want to use this context value on iOS because the `scrollsToTop` prop is iOS-only
200// removing it saves us a re-render on Android
201const useAllowScrollToTop = IS_IOS ? useAllowScrollToTopIOS : () => undefined
202function useAllowScrollToTopIOS() {
203 const {activeLightbox} = useLightbox()
204 return useDeferredValue(!activeLightbox)
205}