Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at feat/custom-appview 399 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/core/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}