Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import React, {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 = React.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}