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