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