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