forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useEffect, useRef, useState} from 'react'
2import {
3 type ScrollView,
4 type StyleProp,
5 View,
6 type ViewStyle,
7} from 'react-native'
8import {msg} from '@lingui/macro'
9import {useLingui} from '@lingui/react'
10
11import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
12import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
13import {atoms as a, tokens, useTheme, web} from '#/alf'
14import {transparentifyColor} from '#/alf/util/colorGeneration'
15import {Button, ButtonIcon} from '#/components/Button'
16import {
17 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft,
18 ArrowRight_Stroke2_Corner0_Rounded as ArrowRight,
19} from '#/components/icons/Arrow'
20import {Text} from '#/components/Typography'
21import {IS_WEB} from '#/env'
22
23/**
24 * Tab component that automatically scrolls the selected tab into view - used for interests
25 * in the Find Follows dialog, Explore screen, etc.
26 */
27export function InterestTabs({
28 onSelectTab,
29 interests,
30 selectedInterest,
31 disabled,
32 interestsDisplayNames,
33 TabComponent = Tab,
34 contentContainerStyle,
35 gutterWidth = tokens.space.lg,
36}: {
37 onSelectTab: (tab: string) => void
38 interests: string[]
39 selectedInterest: string
40 interestsDisplayNames: Record<string, string>
41 /** still allows changing tab, but removes the active state from the selected tab */
42 disabled?: boolean
43 TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>>
44 contentContainerStyle?: StyleProp<ViewStyle>
45 gutterWidth?: number
46}) {
47 const t = useTheme()
48 const {_} = useLingui()
49 const listRef = useRef<ScrollView>(null)
50 const [totalWidth, setTotalWidth] = useState(0)
51 const [scrollX, setScrollX] = useState(0)
52 const [contentWidth, setContentWidth] = useState(0)
53 const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
54 const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
55
56 const onInitialLayout = useNonReactiveCallback(() => {
57 const index = interests.indexOf(selectedInterest)
58 scrollIntoViewIfNeeded(index)
59 })
60
61 useEffect(() => {
62 if (tabOffsets) {
63 onInitialLayout()
64 }
65 }, [tabOffsets, onInitialLayout])
66
67 function scrollIntoViewIfNeeded(index: number) {
68 const btnLayout = tabOffsets[index]
69 if (!btnLayout) return
70 listRef.current?.scrollTo({
71 // centered
72 x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
73 animated: true,
74 })
75 }
76
77 function handleSelectTab(index: number) {
78 const tab = interests[index]
79 onSelectTab(tab)
80 scrollIntoViewIfNeeded(index)
81 }
82
83 function handleTabLayout(index: number, x: number, width: number) {
84 if (!tabOffsets.length) {
85 pendingTabOffsets.current[index] = {x, width}
86 // not only do we check if the length is equal to the number of interests,
87 // but we also need to ensure that the array isn't sparse. `.filter()`
88 // removes any empty slots from the array
89 if (
90 pendingTabOffsets.current.filter(o => !!o).length === interests.length
91 ) {
92 setTabOffsets(pendingTabOffsets.current)
93 }
94 }
95 }
96
97 const canScrollLeft = scrollX > 0
98 const canScrollRight = Math.ceil(scrollX) < contentWidth - totalWidth
99
100 const cleanupRef = useRef<(() => void) | null>(null)
101
102 function scrollLeft() {
103 if (isContinuouslyScrollingRef.current) {
104 return
105 }
106 if (listRef.current && canScrollLeft) {
107 const newScrollX = Math.max(0, scrollX - 200)
108 listRef.current.scrollTo({x: newScrollX, animated: true})
109 }
110 }
111
112 function scrollRight() {
113 if (isContinuouslyScrollingRef.current) {
114 return
115 }
116 if (listRef.current && canScrollRight) {
117 const maxScroll = contentWidth - totalWidth
118 const newScrollX = Math.min(maxScroll, scrollX + 200)
119 listRef.current.scrollTo({x: newScrollX, animated: true})
120 }
121 }
122
123 const isContinuouslyScrollingRef = useRef(false)
124
125 function startContinuousScroll(direction: 'left' | 'right') {
126 // Clear any existing continuous scroll
127 if (cleanupRef.current) {
128 cleanupRef.current()
129 }
130
131 let holdTimeout: NodeJS.Timeout | null = null
132 let animationFrame: number | null = null
133 let isActive = true
134 isContinuouslyScrollingRef.current = false
135
136 const cleanup = () => {
137 isActive = false
138 if (holdTimeout) clearTimeout(holdTimeout)
139 if (animationFrame) cancelAnimationFrame(animationFrame)
140 cleanupRef.current = null
141 // Reset flag after a delay to prevent onPress from firing
142 setTimeout(() => {
143 isContinuouslyScrollingRef.current = false
144 }, 100)
145 }
146
147 cleanupRef.current = cleanup
148
149 // Start continuous scrolling after hold delay
150 holdTimeout = setTimeout(() => {
151 if (!isActive) return
152
153 isContinuouslyScrollingRef.current = true
154 let currentScrollPosition = scrollX
155
156 const scroll = () => {
157 if (!isActive || !listRef.current) return
158
159 const scrollAmount = 3
160 const maxScroll = contentWidth - totalWidth
161
162 let newScrollX: number
163 let canContinue = false
164
165 if (direction === 'left' && currentScrollPosition > 0) {
166 newScrollX = Math.max(0, currentScrollPosition - scrollAmount)
167 canContinue = newScrollX > 0
168 } else if (direction === 'right' && currentScrollPosition < maxScroll) {
169 newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount)
170 canContinue = newScrollX < maxScroll
171 } else {
172 return
173 }
174
175 currentScrollPosition = newScrollX
176 listRef.current.scrollTo({x: newScrollX, animated: false})
177
178 if (canContinue && isActive) {
179 animationFrame = requestAnimationFrame(scroll)
180 }
181 }
182
183 scroll()
184 }, 500)
185 }
186
187 function stopContinuousScroll() {
188 if (cleanupRef.current) {
189 cleanupRef.current()
190 }
191 }
192
193 useEffect(() => {
194 return () => {
195 if (cleanupRef.current) {
196 cleanupRef.current()
197 }
198 }
199 }, [])
200
201 return (
202 <View style={[a.relative, a.flex_row]}>
203 <DraggableScrollView
204 ref={listRef}
205 contentContainerStyle={[
206 a.gap_sm,
207 {paddingHorizontal: gutterWidth},
208 contentContainerStyle,
209 ]}
210 showsHorizontalScrollIndicator={false}
211 decelerationRate="fast"
212 snapToOffsets={
213 tabOffsets.filter(o => !!o).length === interests.length
214 ? tabOffsets.map(o => o.x - tokens.space.xl)
215 : undefined
216 }
217 onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
218 onContentSizeChange={width => setContentWidth(width)}
219 onScroll={evt => {
220 const newScrollX = evt.nativeEvent.contentOffset.x
221 setScrollX(newScrollX)
222 }}
223 scrollEventThrottle={16}>
224 {interests.map((interest, i) => {
225 const active = interest === selectedInterest && !disabled
226 return (
227 <TabComponent
228 key={interest}
229 onSelectTab={handleSelectTab}
230 active={active}
231 index={i}
232 interest={interest}
233 interestsDisplayName={interestsDisplayNames[interest]}
234 onLayout={handleTabLayout}
235 />
236 )
237 })}
238 </DraggableScrollView>
239 {IS_WEB && canScrollLeft && (
240 <View
241 style={[
242 a.absolute,
243 a.top_0,
244 a.left_0,
245 a.bottom_0,
246 a.justify_center,
247 {paddingLeft: gutterWidth},
248 a.pr_md,
249 a.z_10,
250 web({
251 background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
252 }),
253 ]}>
254 <Button
255 label={_(msg`Scroll left`)}
256 onPress={scrollLeft}
257 onPressIn={() => startContinuousScroll('left')}
258 onPressOut={stopContinuousScroll}
259 color="secondary"
260 size="small"
261 style={[
262 a.border,
263 t.atoms.border_contrast_low,
264 t.atoms.bg,
265 a.h_full,
266 a.aspect_square,
267 a.rounded_full,
268 ]}>
269 <ButtonIcon icon={ArrowLeft} />
270 </Button>
271 </View>
272 )}
273 {IS_WEB && canScrollRight && (
274 <View
275 style={[
276 a.absolute,
277 a.top_0,
278 a.right_0,
279 a.bottom_0,
280 a.justify_center,
281 {paddingRight: gutterWidth},
282 a.pl_md,
283 a.z_10,
284 web({
285 background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`,
286 }),
287 ]}>
288 <Button
289 label={_(msg`Scroll right`)}
290 onPress={scrollRight}
291 onPressIn={() => startContinuousScroll('right')}
292 onPressOut={stopContinuousScroll}
293 color="secondary"
294 size="small"
295 style={[
296 a.border,
297 t.atoms.border_contrast_low,
298 t.atoms.bg,
299 a.h_full,
300 a.aspect_square,
301 a.rounded_full,
302 ]}>
303 <ButtonIcon icon={ArrowRight} />
304 </Button>
305 </View>
306 )}
307 </View>
308 )
309}
310
311function Tab({
312 onSelectTab,
313 interest,
314 active,
315 index,
316 interestsDisplayName,
317 onLayout,
318}: {
319 onSelectTab: (index: number) => void
320 interest: string
321 active: boolean
322 index: number
323 interestsDisplayName: string
324 onLayout: (index: number, x: number, width: number) => void
325}) {
326 const t = useTheme()
327 const {_} = useLingui()
328 const label = active
329 ? _(
330 msg({
331 message: `"${interestsDisplayName}" category (active)`,
332 comment:
333 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.',
334 }),
335 )
336 : _(
337 msg({
338 message: `Select "${interestsDisplayName}" category`,
339 comment:
340 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.',
341 }),
342 )
343
344 return (
345 <View
346 key={interest}
347 onLayout={e =>
348 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
349 }>
350 <Button
351 label={label}
352 onPress={() => onSelectTab(index)}
353 // disable focus ring, we handle it
354 style={web({outline: 'none'})}>
355 {({hovered, pressed, focused}) => (
356 <View
357 style={[
358 a.rounded_full,
359 a.px_lg,
360 a.py_sm,
361 a.border,
362 active || hovered || pressed
363 ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
364 : focused
365 ? {
366 borderColor: t.palette.primary_300,
367 backgroundColor: t.palette.primary_25,
368 }
369 : [t.atoms.bg, t.atoms.border_contrast_low],
370 ]}>
371 <Text
372 style={[
373 a.font_medium,
374 active || hovered || pressed
375 ? t.atoms.text
376 : t.atoms.text_contrast_medium,
377 ]}>
378 {interestsDisplayName}
379 </Text>
380 </View>
381 )}
382 </Button>
383 </View>
384 )
385}
386
387export function boostInterests(boosts?: string[]) {
388 return (_a: string, _b: string) => {
389 const indexA = boosts?.indexOf(_a) ?? -1
390 const indexB = boosts?.indexOf(_b) ?? -1
391 const rankA = indexA === -1 ? Infinity : indexA
392 const rankB = indexB === -1 ? Infinity : indexB
393 return rankA - rankB
394 }
395}