Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 204 lines 5.9 kB view raw
1import {useCallback, useEffect} from 'react' 2import {type NativeScrollEvent} from 'react-native' 3import { 4 clamp, 5 interpolate, 6 useSharedValue, 7 withSpring, 8} from 'react-native-reanimated' 9import {useSafeAreaInsets} from 'react-native-safe-area-context' 10import {EventEmitter} from 'eventemitter3' 11 12import {ScrollProvider} from '#/lib/ScrollContext' 13import {useMinimalShellMode} from '#/state/shell' 14import {useShellLayout} from '#/state/shell/shell-layout' 15import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 16 17const WEB_HIDE_SHELL_THRESHOLD = 200 18 19export function MainScrollProvider({children}: {children: React.ReactNode}) { 20 const {headerHeight} = useShellLayout() 21 const {headerMode} = useMinimalShellMode() 22 const {top: topInset} = useSafeAreaInsets() 23 const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 24 const startDragOffset = useSharedValue<number | null>(null) 25 const startMode = useSharedValue<number | null>(null) 26 const didJustRestoreScroll = useSharedValue<boolean>(false) 27 28 const setMode = useCallback( 29 (v: boolean) => { 30 'worklet' 31 headerMode.set(() => 32 withSpring(v ? 1 : 0, { 33 overshootClamping: true, 34 }), 35 ) 36 }, 37 [headerMode], 38 ) 39 40 useEffect(() => { 41 if (IS_WEB) { 42 return listenToForcedWindowScroll(() => { 43 startDragOffset.set(null) 44 startMode.set(null) 45 didJustRestoreScroll.set(true) 46 }) 47 } 48 }) 49 50 const snapToClosestState = useCallback( 51 (e: NativeScrollEvent) => { 52 'worklet' 53 const offsetY = Math.max(0, e.contentOffset.y) 54 if (IS_NATIVE) { 55 const startDragOffsetValue = startDragOffset.get() 56 if (startDragOffsetValue === null) { 57 return 58 } 59 const didScrollDown = offsetY > startDragOffsetValue 60 startDragOffset.set(null) 61 startMode.set(null) 62 if (offsetY < headerHeight.get()) { 63 // If we're close to the top, show the shell. 64 setMode(false) 65 } else if (didScrollDown) { 66 // Showing the bar again on scroll down feels annoying, so don't. 67 setMode(true) 68 } else { 69 // Snap to whichever state is the closest. 70 setMode(Math.round(headerMode.get()) === 1) 71 } 72 } 73 }, 74 [startDragOffset, startMode, setMode, headerMode, headerHeight], 75 ) 76 77 const onBeginDrag = useCallback( 78 (e: NativeScrollEvent) => { 79 'worklet' 80 const offsetY = Math.max(0, e.contentOffset.y) 81 if (IS_NATIVE) { 82 startDragOffset.set(offsetY) 83 startMode.set(headerMode.get()) 84 } 85 }, 86 [headerMode, startDragOffset, startMode], 87 ) 88 89 const onEndDrag = useCallback( 90 (e: NativeScrollEvent) => { 91 'worklet' 92 if (IS_NATIVE) { 93 if (e.velocity && e.velocity.y !== 0) { 94 // If we detect a velocity, wait for onMomentumEnd to snap. 95 return 96 } 97 snapToClosestState(e) 98 } 99 }, 100 [snapToClosestState], 101 ) 102 103 const onMomentumEnd = useCallback( 104 (e: NativeScrollEvent) => { 105 'worklet' 106 if (IS_NATIVE) { 107 snapToClosestState(e) 108 } 109 }, 110 [snapToClosestState], 111 ) 112 113 const onScroll = useCallback( 114 (e: NativeScrollEvent) => { 115 'worklet' 116 const offsetY = Math.max(0, e.contentOffset.y) 117 if (IS_NATIVE) { 118 const startDragOffsetValue = startDragOffset.get() 119 const startModeValue = startMode.get() 120 if (startDragOffsetValue === null || startModeValue === null) { 121 if (headerMode.get() !== 0 && offsetY < headerHeight.get()) { 122 // If we're close enough to the top, always show the shell. 123 // Even if we're not dragging. 124 setMode(false) 125 } 126 return 127 } 128 129 // The "mode" value is always between 0 and 1. 130 // Figure out how much to move it based on the current dragged distance. 131 const dy = offsetY - startDragOffsetValue 132 const hideDistance = headerHeight.get() - headerPinnedHeight 133 const dProgress = interpolate( 134 dy, 135 [-hideDistance, hideDistance], 136 [-1, 1], 137 ) 138 const newValue = clamp(startModeValue + dProgress, 0, 1) 139 if (newValue !== headerMode.get()) { 140 // Manually adjust the value. This won't be (and shouldn't be) animated. 141 headerMode.set(newValue) 142 } 143 } else { 144 if (didJustRestoreScroll.get()) { 145 didJustRestoreScroll.set(false) 146 // Don't hide/show navbar based on scroll restoratoin. 147 return 148 } 149 // On the web, we don't try to follow the drag because we don't know when it ends. 150 // Instead, show/hide immediately based on whether we're scrolling up or down. 151 const dy = offsetY - (startDragOffset.get() ?? 0) 152 startDragOffset.set(offsetY) 153 154 if (dy < 0 || offsetY < WEB_HIDE_SHELL_THRESHOLD) { 155 setMode(false) 156 } else if (dy > 0) { 157 setMode(true) 158 } 159 } 160 }, 161 [ 162 headerHeight, 163 headerPinnedHeight, 164 headerMode, 165 setMode, 166 startDragOffset, 167 startMode, 168 didJustRestoreScroll, 169 ], 170 ) 171 172 return ( 173 <ScrollProvider 174 onBeginDrag={onBeginDrag} 175 onEndDrag={onEndDrag} 176 onScroll={onScroll} 177 onMomentumEnd={onMomentumEnd}> 178 {children} 179 </ScrollProvider> 180 ) 181} 182 183const emitter = new EventEmitter() 184 185if (IS_WEB) { 186 const originalScroll = window.scroll 187 window.scroll = function () { 188 emitter.emit('forced-scroll') 189 return originalScroll.apply(this, arguments as any) 190 } 191 192 const originalScrollTo = window.scrollTo 193 window.scrollTo = function () { 194 emitter.emit('forced-scroll') 195 return originalScrollTo.apply(this, arguments as any) 196 } 197} 198 199function listenToForcedWindowScroll(listener: () => void) { 200 emitter.addListener('forced-scroll', listener) 201 return () => { 202 emitter.removeListener('forced-scroll', listener) 203 } 204}