Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 82f42e734c50b34de31e8aff1e7ced248ab6e96f 395 lines 12 kB view raw
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}