Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at theme-changes 490 lines 15 kB view raw
1import {useLayoutEffect, useRef} from 'react' 2import {Gesture, GestureDetector} from 'react-native-gesture-handler' 3import Animated, { 4 type AnimatedRef, 5 measure, 6 runOnJS, 7 scrollTo, 8 type SharedValue, 9 useAnimatedRef, 10 useAnimatedStyle, 11 useFrameCallback, 12 useSharedValue, 13 withSpring, 14 withTiming, 15} from 'react-native-reanimated' 16 17import {useHaptics} from '#/lib/haptics' 18import {atoms as a, useTheme, web} from '#/alf' 19import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' 20import {IS_IOS} from '#/env' 21 22/** 23 * Drag-to-reorder list. Items are absolutely positioned in a fixed-height 24 * container and animated via Reanimated shared values on the UI thread. 25 * 26 * All positioning is driven by a `slots` map (key → index) and translateY 27 * (no discrete `top` changes). On drag end the new slot assignment is 28 * computed on the UI thread first, then React state is updated via runOnJS. 29 * 30 * See SortableList.web.tsx for the web implementation using pointer events. 31 */ 32 33interface SortableListProps<T> { 34 data: T[] 35 keyExtractor: (item: T) => string 36 renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode 37 onReorder: (data: T[]) => void 38 onDragStart?: () => void 39 onDragEnd?: () => void 40 /** Fixed row height used for position math. */ 41 itemHeight: number 42 /** Ref to the parent Animated.ScrollView for auto-scroll. */ 43 scrollRef?: AnimatedRef<Animated.ScrollView> 44 /** Scroll offset shared value from useScrollViewOffset. */ 45 scrollOffset?: SharedValue<number> 46} 47 48const AUTO_SCROLL_THRESHOLD = 50 49const AUTO_SCROLL_SPEED = 4 50 51/** 52 * Bundled into a single shared value so all fields update atomically 53 * in one set() call on the UI thread. 54 */ 55interface DragState { 56 /** Maps each item key to its current slot index. */ 57 slots: Record<string, number> 58 /** Key of the item being dragged, or '' when idle. */ 59 activeKey: string 60 /** Slot the active item started in. */ 61 dragStartSlot: number 62} 63 64export function SortableList<T>({ 65 data, 66 keyExtractor, 67 renderItem, 68 onReorder, 69 onDragStart, 70 onDragEnd, 71 itemHeight, 72 scrollRef, 73 scrollOffset, 74}: SortableListProps<T>) { 75 const t = useTheme() 76 const state = useSharedValue<DragState>({ 77 slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])), 78 activeKey: '', 79 dragStartSlot: -1, 80 }) 81 const dragY = useSharedValue(0) 82 83 // Auto-scroll shared values 84 const scrollCompensation = useSharedValue(0) 85 const isGestureActive = useSharedValue(false) 86 // We track scroll position ourselves because scrollOffset.get() lags 87 // by one frame after scrollTo(), causing a feedback loop where the 88 // frame callback keeps thinking the item is at the edge. 89 const trackedScrollY = useSharedValue(0) 90 91 // For measuring list position within scroll content 92 const listRef = useAnimatedRef<Animated.View>() 93 const listContentOffset = useSharedValue(0) 94 const viewportHeight = useSharedValue(0) 95 const measureDone = useSharedValue(false) 96 97 // Sync slots when data changes externally (e.g. pin/unpin). 98 // Skip after our own reorder — the worklet already set correct slots 99 // on the UI thread, and a redundant JS-side set() would be wasteful. 100 const skipNextSync = useRef(false) 101 const currentKeys = data.map(item => keyExtractor(item)).join(',') 102 useLayoutEffect(() => { 103 if (skipNextSync.current) { 104 skipNextSync.current = false 105 return 106 } 107 const nextSlots: Record<string, number> = {} 108 data.forEach((item, i) => { 109 nextSlots[keyExtractor(item)] = i 110 }) 111 state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1}) 112 dragY.set(0) 113 }, [currentKeys, data, keyExtractor, state, dragY]) 114 115 const handleReorder = (sortedKeys: string[]) => { 116 skipNextSync.current = true 117 const byKey = new Map(data.map(item => [keyExtractor(item), item])) 118 onReorder(sortedKeys.map(key => byKey.get(key)!)) 119 onDragEnd?.() 120 } 121 122 // Auto-scroll: runs every frame while a gesture is active. 123 useFrameCallback(() => { 124 if (!isGestureActive.get()) return 125 if (!scrollRef || !scrollOffset) return 126 127 const s = state.get() 128 if (s.activeKey === '') return 129 130 // Measure list and scroll view on first frame of drag. 131 // Use scrollOffset here (only once) since no lag has occurred yet. 132 if (!measureDone.get()) { 133 const scrollM = measure( 134 scrollRef as unknown as AnimatedRef<Animated.View>, 135 ) 136 const listM = measure(listRef) 137 if (!scrollM || !listM) return 138 trackedScrollY.set(scrollOffset.get()) 139 listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get()) 140 viewportHeight.set(scrollM.height) 141 measureDone.set(true) 142 } 143 144 const startSlot = s.dragStartSlot 145 const currentDragY = dragY.get() 146 147 // Use trackedScrollY (not scrollOffset) to avoid the one-frame lag 148 // after scrollTo() that causes a feedback loop. 149 const scrollY = trackedScrollY.get() 150 151 // Item position relative to scroll viewport top. 152 const itemContentY = 153 listContentOffset.get() + startSlot * itemHeight + currentDragY 154 const itemViewportY = itemContentY - scrollY 155 const itemBottomViewportY = itemViewportY + itemHeight 156 157 let scrollDelta = 0 158 if (itemViewportY < AUTO_SCROLL_THRESHOLD) { 159 scrollDelta = -AUTO_SCROLL_SPEED 160 } else if ( 161 itemBottomViewportY > 162 viewportHeight.get() - AUTO_SCROLL_THRESHOLD 163 ) { 164 scrollDelta = AUTO_SCROLL_SPEED 165 } 166 167 if (scrollDelta === 0) return 168 169 // Don't scroll if the item is already at a list boundary. 170 const effectiveSlotPos = 171 (startSlot * itemHeight + currentDragY) / itemHeight 172 if (scrollDelta < 0 && effectiveSlotPos <= 0) return 173 if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return 174 175 // Don't scroll past the top. 176 if (scrollDelta < 0 && scrollY <= 0) return 177 178 const newScrollY = Math.max(0, scrollY + scrollDelta) 179 scrollTo(scrollRef, 0, newScrollY, false) 180 trackedScrollY.set(newScrollY) 181 scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY)) 182 }) 183 184 // Render in stable key order so React never reorders native views. 185 // On Android, native ViewGroup child reordering causes a visual flash. 186 const sortedData = [...data].sort((a, b) => { 187 const ka = keyExtractor(a) 188 const kb = keyExtractor(b) 189 return ka < kb ? -1 : ka > kb ? 1 : 0 190 }) 191 192 return ( 193 <Animated.View 194 ref={listRef} 195 style={[{height: data.length * itemHeight}, t.atoms.bg_contrast_25]}> 196 {sortedData.map(item => { 197 const key = keyExtractor(item) 198 return ( 199 <SortableItem 200 key={key} 201 item={item} 202 itemKey={key} 203 itemCount={data.length} 204 itemHeight={itemHeight} 205 state={state} 206 dragY={dragY} 207 scrollCompensation={scrollCompensation} 208 isGestureActive={isGestureActive} 209 measureDone={measureDone} 210 renderItem={renderItem} 211 onCommitReorder={handleReorder} 212 onDragStart={onDragStart} 213 onDragEnd={onDragEnd} 214 /> 215 ) 216 })} 217 </Animated.View> 218 ) 219} 220 221function SortableItem<T>({ 222 item, 223 itemKey, 224 itemCount, 225 itemHeight, 226 state, 227 dragY, 228 scrollCompensation, 229 isGestureActive, 230 measureDone, 231 renderItem, 232 onCommitReorder, 233 onDragStart, 234 onDragEnd, 235}: { 236 item: T 237 itemKey: string 238 itemCount: number 239 itemHeight: number 240 state: Animated.SharedValue<DragState> 241 dragY: Animated.SharedValue<number> 242 scrollCompensation: SharedValue<number> 243 isGestureActive: SharedValue<boolean> 244 measureDone: SharedValue<boolean> 245 renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode 246 onCommitReorder: (sortedKeys: string[]) => void 247 onDragStart?: () => void 248 onDragEnd?: () => void 249}) { 250 const t = useTheme() 251 const playHaptic = useHaptics() 252 253 const lastHapticSlot = useSharedValue(-1) 254 255 const gesture = Gesture.Pan() 256 .onStart(() => { 257 'worklet' 258 const s = state.get() 259 const mySlot = s.slots[itemKey] 260 state.set({...s, activeKey: itemKey, dragStartSlot: mySlot}) 261 dragY.set(0) 262 scrollCompensation.set(0) 263 isGestureActive.set(true) 264 measureDone.set(false) 265 lastHapticSlot.set(mySlot) 266 if (onDragStart) { 267 runOnJS(onDragStart)() 268 } 269 runOnJS(playHaptic)() 270 }) 271 .onChange(e => { 272 'worklet' 273 const startSlot = state.get().dragStartSlot 274 const minY = -startSlot * itemHeight 275 const maxY = (itemCount - 1 - startSlot) * itemHeight 276 // Include scroll compensation so the item tracks with auto-scroll. 277 const effectiveY = e.translationY + scrollCompensation.get() 278 const clampedY = Math.max(minY, Math.min(effectiveY, maxY)) 279 dragY.set(clampedY) 280 281 const currentSlot = Math.round( 282 (startSlot * itemHeight + clampedY) / itemHeight, 283 ) 284 const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1)) 285 if (IS_IOS && clampedSlot !== lastHapticSlot.get()) { 286 lastHapticSlot.set(clampedSlot) 287 runOnJS(playHaptic)('Light') 288 } 289 }) 290 .onEnd(() => { 291 'worklet' 292 // Stop auto-scroll BEFORE the snap animation. 293 isGestureActive.set(false) 294 const startSlot = state.get().dragStartSlot 295 const rawNewSlot = Math.round( 296 (startSlot * itemHeight + dragY.get()) / itemHeight, 297 ) 298 const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1)) 299 const snapOffset = (newSlot - startSlot) * itemHeight 300 301 // Animate to the target slot, then commit. 302 dragY.set( 303 withTiming(snapOffset, {duration: 200}, finished => { 304 if (finished) { 305 if (newSlot !== startSlot) { 306 // Compute new slots on the UI thread so animated styles 307 // reflect final positions before React re-renders. 308 const cur = state.get() 309 const sorted: string[] = new Array(itemCount) 310 for (const key in cur.slots) { 311 sorted[cur.slots[key]] = key 312 } 313 const movedKey = sorted[startSlot] 314 sorted.splice(startSlot, 1) 315 sorted.splice(newSlot, 0, movedKey) 316 317 const nextSlots: Record<string, number> = {} 318 for (let i = 0; i < sorted.length; i++) { 319 nextSlots[sorted[i]] = i 320 } 321 322 state.set({ 323 slots: nextSlots, 324 activeKey: '', 325 dragStartSlot: -1, 326 }) 327 dragY.set(0) 328 runOnJS(onCommitReorder)(sorted) 329 } else { 330 const s = state.get() 331 state.set({...s, activeKey: '', dragStartSlot: -1}) 332 dragY.set(0) 333 if (onDragEnd) { 334 runOnJS(onDragEnd)() 335 } 336 } 337 } 338 }), 339 ) 340 }) 341 // Reset if the gesture is cancelled without onEnd firing. 342 .onFinalize(() => { 343 'worklet' 344 isGestureActive.set(false) 345 if (state.get().activeKey === itemKey && dragY.get() === 0) { 346 const s = state.get() 347 state.set({...s, activeKey: '', dragStartSlot: -1}) 348 if (onDragEnd) { 349 runOnJS(onDragEnd)() 350 } 351 } 352 }) 353 354 // All vertical positioning is via translateY (no `top`). This avoids 355 // discrete jumps when slots change — Reanimated smoothly animates from 356 // the current translateY to the new target on every state transition. 357 // On first mount we skip the animation so items appear instantly. 358 const isFirstRender = useSharedValue(true) 359 360 const animatedStyle = useAnimatedStyle(() => { 361 const s = state.get() 362 const mySlot = s.slots[itemKey] 363 if (mySlot === undefined) { 364 return {} 365 } 366 const baseY = mySlot * itemHeight 367 368 // Active item: follow the finger with a slight scale-up and shadow. 369 if (s.activeKey === itemKey) { 370 return { 371 transform: [ 372 {translateY: s.dragStartSlot * itemHeight + dragY.get()}, 373 {scale: withSpring(1.03)}, 374 ], 375 zIndex: 999, 376 ...(IS_IOS 377 ? { 378 shadowColor: '#000', 379 shadowOffset: {width: 0, height: 1}, 380 shadowOpacity: withSpring(0.08), 381 shadowRadius: withSpring(4), 382 } 383 : { 384 elevation: withSpring(3), 385 }), 386 } 387 } 388 389 // Reset for non-active states. Without this, shadow props 390 // set during dragging linger on the native view. 391 const inactive = { 392 ...(IS_IOS 393 ? { 394 shadowOpacity: withSpring(0), 395 shadowRadius: withSpring(0), 396 } 397 : { 398 elevation: withSpring(0), 399 }), 400 } 401 402 // Another item is being dragged — shift to make room. 403 if (s.activeKey !== '') { 404 isFirstRender.set(false) 405 const currentDragPos = Math.round( 406 (s.dragStartSlot * itemHeight + dragY.get()) / itemHeight, 407 ) 408 const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1)) 409 410 let offset = 0 411 if ( 412 s.dragStartSlot < clampedPos && 413 mySlot > s.dragStartSlot && 414 mySlot <= clampedPos 415 ) { 416 offset = -itemHeight 417 } else if ( 418 s.dragStartSlot > clampedPos && 419 mySlot < s.dragStartSlot && 420 mySlot >= clampedPos 421 ) { 422 offset = itemHeight 423 } 424 425 return { 426 transform: [ 427 {translateY: withTiming(baseY + offset, {duration: 200})}, 428 {scale: withSpring(1)}, 429 ], 430 zIndex: 0, 431 ...inactive, 432 } 433 } 434 435 // Idle: sit at our slot. On first render use a direct value so items 436 // don't animate from y=0. After any drag, use withTiming so the 437 // shift→idle transition is smooth (no discrete jump). 438 if (isFirstRender.get()) { 439 isFirstRender.set(false) 440 return { 441 transform: [{translateY: baseY}, {scale: 1}], 442 zIndex: 0, 443 ...inactive, 444 } 445 } 446 447 return { 448 transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}], 449 zIndex: 0, 450 ...inactive, 451 } 452 }) 453 454 const dragHandle = ( 455 <GestureDetector gesture={gesture}> 456 <Animated.View 457 testID="feed-drag-handle" 458 style={[ 459 a.justify_center, 460 a.align_center, 461 a.px_sm, 462 a.py_md, 463 web({cursor: 'grab'}), 464 ]} 465 hitSlop={{top: 8, bottom: 8, left: 8, right: 8}}> 466 <GripIcon 467 size="lg" 468 fill={t.atoms.text_contrast_medium.color} 469 style={web({pointerEvents: 'none'})} 470 /> 471 </Animated.View> 472 </GestureDetector> 473 ) 474 475 return ( 476 <Animated.View 477 style={[ 478 { 479 position: 'absolute', 480 top: 0, 481 left: 0, 482 right: 0, 483 height: itemHeight, 484 }, 485 animatedStyle, 486 ]}> 487 {renderItem(item, dragHandle)} 488 </Animated.View> 489 ) 490}