Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Fixes for thread composer on Android (#6045)

* Extract function to read contentHeight later

* Remove autoscroll to bottom

We're going to implement this in the UI layer instead.

* Remove worklet from non-worklets to avoid confusion

* Rename and invert hasScrolled* variables

Their naming was too ambiguous (they used to represent "has scrolled _away_ from X"). I inverted them and clarified the naming. No functional changes.

* This should not be necessary

It's already called not just from UI thread. And it only sets shared values, which can be done from either thread.

* Make hasScrolledTo* derived values

It wasn't always correct to derive them manually because reading from .value is stale on JS thread. We could fix that by using the local variables but it makes more conceptualy sense to treat these as derived anyway.

* Reimplement autoscroll-to-bottom in UI layer

Doing it here ensures we also do it when you add an image at the end of the thread. Otherwise it's very confusing where it went.

* Use fancy ScrollView

This seems to fix ScrollView getting stuck after inserting images at the thread end on Android.

* More aggressive scroll-to-bottom

* "Fix" cursor getting stuck on Android

* Revert "Use fancy ScrollView"

This reverts commit 04e34a54e3b75f8a77de5062bff5fe6e76420bbb.

authored by

dan and committed by
GitHub
21b82fa1 7a08d61d

+52 -37
+52 -33
src/view/com/composer/Composer.tsx
··· 22 22 // @ts-expect-error no type definition 23 23 import ProgressCircle from 'react-native-progress/Circle' 24 24 import Animated, { 25 + AnimatedRef, 25 26 Easing, 26 27 FadeIn, 27 28 FadeOut, ··· 507 508 useEffect(() => { 508 509 if (composerState.mutableNeedsFocusActive) { 509 510 composerState.mutableNeedsFocusActive = false 510 - textInput.current?.focus() 511 + // On Android, this risks getting the cursor stuck behind the keyboard. 512 + // Not worth it. 513 + if (!isAndroid) { 514 + textInput.current?.focus() 515 + } 511 516 } 512 517 }, [composerState]) 513 518 514 519 const { 515 - contentHeight, 516 520 scrollHandler, 517 521 onScrollViewContentSizeChange, 518 522 onScrollViewLayout, 519 523 topBarAnimatedStyle, 520 524 bottomBarAnimatedStyle, 521 - } = useAnimatedBorders() 522 - 523 - useEffect(() => { 524 - if (composerState.mutableNeedsScrollToBottom) { 525 - composerState.mutableNeedsScrollToBottom = false 526 - runOnUI(scrollTo)(scrollViewRef, 0, contentHeight.value, true) 527 - } 528 - }, [composerState, scrollViewRef, contentHeight]) 525 + } = useScrollTracker({ 526 + scrollViewRef, 527 + stickyBottom: true, 528 + }) 529 529 530 530 const keyboardVerticalOffset = useKeyboardVerticalOffset() 531 531 ··· 1213 1213 return useRef<CancelRef>(null) 1214 1214 } 1215 1215 1216 - function useAnimatedBorders() { 1216 + function useScrollTracker({ 1217 + scrollViewRef, 1218 + stickyBottom, 1219 + }: { 1220 + scrollViewRef: AnimatedRef<Animated.ScrollView> 1221 + stickyBottom: boolean 1222 + }) { 1217 1223 const t = useTheme() 1218 - const hasScrolledTop = useSharedValue(0) 1219 - const hasScrolledBottom = useSharedValue(0) 1220 1224 const contentOffset = useSharedValue(0) 1221 1225 const scrollViewHeight = useSharedValue(Infinity) 1222 1226 const contentHeight = useSharedValue(0) 1223 1227 1224 - /** 1225 - * Make sure to run this on the UI thread! 1226 - */ 1228 + const hasScrolledToTop = useDerivedValue(() => 1229 + withTiming(contentOffset.value === 0 ? 1 : 0), 1230 + ) 1231 + 1232 + const hasScrolledToBottom = useDerivedValue(() => 1233 + withTiming( 1234 + contentHeight.value - contentOffset.value - 5 <= scrollViewHeight.value 1235 + ? 1 1236 + : 0, 1237 + ), 1238 + ) 1239 + 1227 1240 const showHideBottomBorder = useCallback( 1228 1241 ({ 1229 1242 newContentHeight, ··· 1235 1248 newScrollViewHeight?: number 1236 1249 }) => { 1237 1250 'worklet' 1238 - 1239 1251 if (typeof newContentHeight === 'number') 1240 1252 contentHeight.value = Math.floor(newContentHeight) 1241 1253 if (typeof newContentOffset === 'number') 1242 1254 contentOffset.value = Math.floor(newContentOffset) 1243 1255 if (typeof newScrollViewHeight === 'number') 1244 1256 scrollViewHeight.value = Math.floor(newScrollViewHeight) 1245 - 1246 - hasScrolledBottom.value = withTiming( 1247 - contentHeight.value - contentOffset.value - 5 > scrollViewHeight.value 1248 - ? 1 1249 - : 0, 1250 - ) 1251 1257 }, 1252 - [contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom], 1258 + [contentHeight, contentOffset, scrollViewHeight], 1253 1259 ) 1254 1260 1255 1261 const scrollHandler = useAnimatedScrollHandler({ 1256 1262 onScroll: event => { 1257 1263 'worklet' 1258 - hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) 1259 1264 showHideBottomBorder({ 1260 1265 newContentOffset: event.contentOffset.y, 1261 1266 newContentHeight: event.contentSize.height, ··· 1266 1271 1267 1272 const onScrollViewContentSizeChange = useCallback( 1268 1273 (_width: number, height: number) => { 1269 - 'worklet' 1274 + if (stickyBottom && height > contentHeight.value) { 1275 + const isFairlyCloseToBottom = 1276 + contentHeight.value - contentOffset.value - 100 <= 1277 + scrollViewHeight.value 1278 + if (isFairlyCloseToBottom) { 1279 + runOnUI(() => { 1280 + scrollTo(scrollViewRef, 0, contentHeight.value, true) 1281 + })() 1282 + } 1283 + } 1270 1284 showHideBottomBorder({ 1271 1285 newContentHeight: height, 1272 1286 }) 1273 1287 }, 1274 - [showHideBottomBorder], 1288 + [ 1289 + showHideBottomBorder, 1290 + scrollViewRef, 1291 + contentHeight, 1292 + stickyBottom, 1293 + contentOffset, 1294 + scrollViewHeight, 1295 + ], 1275 1296 ) 1276 1297 1277 1298 const onScrollViewLayout = useCallback( 1278 1299 (evt: LayoutChangeEvent) => { 1279 - 'worklet' 1280 1300 showHideBottomBorder({ 1281 1301 newScrollViewHeight: evt.nativeEvent.layout.height, 1282 1302 }) ··· 1288 1308 return { 1289 1309 borderBottomWidth: StyleSheet.hairlineWidth, 1290 1310 borderColor: interpolateColor( 1291 - hasScrolledTop.value, 1311 + hasScrolledToTop.value, 1292 1312 [0, 1], 1293 - ['transparent', t.atoms.border_contrast_medium.borderColor], 1313 + [t.atoms.border_contrast_medium.borderColor, 'transparent'], 1294 1314 ), 1295 1315 } 1296 1316 }) ··· 1298 1318 return { 1299 1319 borderTopWidth: StyleSheet.hairlineWidth, 1300 1320 borderColor: interpolateColor( 1301 - hasScrolledBottom.value, 1321 + hasScrolledToBottom.value, 1302 1322 [0, 1], 1303 - ['transparent', t.atoms.border_contrast_medium.borderColor], 1323 + [t.atoms.border_contrast_medium.borderColor, 'transparent'], 1304 1324 ), 1305 1325 } 1306 1326 }) 1307 1327 1308 1328 return { 1309 - contentHeight, 1310 1329 scrollHandler, 1311 1330 onScrollViewContentSizeChange, 1312 1331 onScrollViewLayout,
-4
src/view/com/composer/state/composer.ts
··· 87 87 thread: ThreadDraft 88 88 activePostIndex: number 89 89 mutableNeedsFocusActive: boolean 90 - mutableNeedsScrollToBottom: boolean 91 90 } 92 91 93 92 export type ComposerAction = ··· 157 156 } 158 157 case 'add_post': { 159 158 const activePostIndex = state.activePostIndex 160 - const isAtTheEnd = activePostIndex === state.thread.posts.length - 1 161 159 const nextPosts = [...state.thread.posts] 162 160 nextPosts.splice(activePostIndex + 1, 0, { 163 161 id: nanoid(), ··· 172 170 }) 173 171 return { 174 172 ...state, 175 - mutableNeedsScrollToBottom: isAtTheEnd, 176 173 thread: { 177 174 ...state.thread, 178 175 posts: nextPosts, ··· 514 511 return { 515 512 activePostIndex: 0, 516 513 mutableNeedsFocusActive: false, 517 - mutableNeedsScrollToBottom: false, 518 514 thread: { 519 515 posts: [ 520 516 {