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