Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 forwardRef,
3 type JSX,
4 useCallback,
5 useEffect,
6 useImperativeHandle,
7 useMemo,
8 useRef,
9 useState,
10} from 'react'
11import {
12 type NativeScrollEvent,
13 type NativeSyntheticEvent,
14 Pressable,
15 RefreshControl,
16 ScrollView,
17 StyleSheet,
18 View,
19} from 'react-native'
20
21import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
22import {usePalette} from '#/lib/hooks/usePalette'
23import {clamp} from '#/lib/numbers'
24import {colors, s} from '#/lib/styles'
25import {IS_ANDROID} from '#/env'
26import {Text} from './text/Text'
27import {FlatList_INTERNAL} from './Views'
28
29const HEADER_ITEM = {_reactKey: '__header__'}
30const SELECTOR_ITEM = {_reactKey: '__selector__'}
31const STICKY_HEADER_INDICES = [1]
32
33export type ViewSelectorHandle = {
34 scrollToTop: () => void
35}
36
37export const ViewSelector = forwardRef<
38 ViewSelectorHandle,
39 {
40 sections: string[]
41 items: any[]
42 refreshing?: boolean
43 swipeEnabled?: boolean
44 renderHeader?: () => JSX.Element
45 renderItem: (item: any) => JSX.Element
46 ListFooterComponent?:
47 | React.ComponentType<any>
48 | React.ReactElement<any>
49 | null
50 | undefined
51 onSelectView?: (viewIndex: number) => void
52 onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
53 onRefresh?: () => void
54 onEndReached?: (info: {distanceFromEnd: number}) => void
55 }
56>(function ViewSelectorImpl(
57 {
58 sections,
59 items,
60 refreshing,
61 renderHeader,
62 renderItem,
63 ListFooterComponent,
64 onSelectView,
65 onScroll,
66 onRefresh,
67 onEndReached,
68 },
69 ref,
70) {
71 const pal = usePalette('default')
72 const [selectedIndex, setSelectedIndex] = useState<number>(0)
73 const flatListRef = useRef<FlatList_INTERNAL>(null)
74
75 // events
76 // =
77
78 const keyExtractor = useCallback((item: any) => item._reactKey, [])
79
80 const onPressSelection = useCallback(
81 (index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
82 [setSelectedIndex, sections],
83 )
84 useEffect(() => {
85 onSelectView?.(selectedIndex)
86 }, [selectedIndex, onSelectView])
87
88 useImperativeHandle(ref, () => ({
89 scrollToTop: () => {
90 flatListRef.current?.scrollToOffset({offset: 0})
91 },
92 }))
93
94 // rendering
95 // =
96
97 const renderItemInternal = useCallback(
98 ({item}: {item: any}) => {
99 if (item === HEADER_ITEM) {
100 if (renderHeader) {
101 return renderHeader()
102 }
103 return <View />
104 } else if (item === SELECTOR_ITEM) {
105 return (
106 <Selector
107 items={sections}
108 selectedIndex={selectedIndex}
109 onSelect={onPressSelection}
110 />
111 )
112 } else {
113 return renderItem(item)
114 }
115 },
116 [sections, selectedIndex, onPressSelection, renderHeader, renderItem],
117 )
118
119 const data = useMemo(() => [HEADER_ITEM, SELECTOR_ITEM, ...items], [items])
120 return (
121 <FlatList_INTERNAL
122 // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
123 ref={flatListRef}
124 data={data}
125 keyExtractor={keyExtractor}
126 renderItem={renderItemInternal}
127 ListFooterComponent={ListFooterComponent}
128 // NOTE sticky header disabled on android due to major performance issues -prf
129 stickyHeaderIndices={IS_ANDROID ? undefined : STICKY_HEADER_INDICES}
130 onScroll={onScroll}
131 onEndReached={onEndReached}
132 refreshControl={
133 <RefreshControl
134 refreshing={refreshing!}
135 onRefresh={onRefresh}
136 tintColor={pal.colors.text}
137 />
138 }
139 onEndReachedThreshold={0.6}
140 contentContainerStyle={s.contentContainer}
141 removeClippedSubviews={true}
142 scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464
143 />
144 )
145})
146
147export function Selector({
148 selectedIndex,
149 items,
150 onSelect,
151}: {
152 selectedIndex: number
153 items: string[]
154 onSelect?: (index: number) => void
155}) {
156 const pal = usePalette('default')
157 const borderColor = useColorSchemeStyle(
158 {borderColor: colors.black},
159 {borderColor: colors.white},
160 )
161
162 const onPressItem = (index: number) => {
163 onSelect?.(index)
164 }
165
166 return (
167 <View
168 style={{
169 width: '100%',
170 backgroundColor: pal.colors.background,
171 }}>
172 <ScrollView
173 testID="selector"
174 horizontal
175 showsHorizontalScrollIndicator={false}>
176 <View style={[pal.view, styles.outer]}>
177 {items.map((item, i) => {
178 const selected = i === selectedIndex
179 return (
180 <Pressable
181 testID={`selector-${i}`}
182 key={item}
183 onPress={() => onPressItem(i)}
184 accessibilityLabel={item}
185 accessibilityHint={`Selects ${item}`}
186 // TODO: Modify the component API such that lint fails
187 // at the invocation site as well
188 >
189 <View
190 style={[
191 styles.item,
192 selected && styles.itemSelected,
193 borderColor,
194 ]}>
195 <Text
196 style={
197 selected
198 ? [styles.labelSelected, pal.text]
199 : [styles.label, pal.textLight]
200 }>
201 {item}
202 </Text>
203 </View>
204 </Pressable>
205 )
206 })}
207 </View>
208 </ScrollView>
209 </View>
210 )
211}
212
213const styles = StyleSheet.create({
214 outer: {
215 flexDirection: 'row',
216 paddingHorizontal: 14,
217 },
218 item: {
219 marginRight: 14,
220 paddingHorizontal: 10,
221 paddingTop: 8,
222 paddingBottom: 12,
223 },
224 itemSelected: {
225 borderBottomWidth: 3,
226 },
227 label: {
228 fontWeight: '600',
229 },
230 labelSelected: {
231 fontWeight: '600',
232 },
233 underline: {
234 position: 'absolute',
235 height: 4,
236 bottom: 0,
237 },
238})