[🐴] 🤞 This should be the final message list change - Use `dispatchCommand` so we don't need to know the content height (#4090)

* handle keyboard scroll more elegantly

simplify

missing `runOnUI`

better naming to avoid confusion

nit

remove unused function

use `dispatchCommand` in `onContentSizeChanged` as well

use `dispatchCommand` so we don't need to know the content height

remove `isMomentumScrolling`

* better timing

* nit

* another nit

* handle message input resizes better too

* account for other size changes like emoji keyboard opening

* one last nit

* just adding comments

* account for dragging

* make it easier to read

* add a comment

* 🤦‍♀️

* remove a little bit of that padding at the top
zio/stable
Hailey 2024-05-18 12:29:23 -07:00 committed by GitHub
parent 2eaecfcaa4
commit 5343910570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 106 deletions

View File

@ -27,10 +27,8 @@ import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/ico
export function MessageInput({ export function MessageInput({
onSendMessage, onSendMessage,
scrollToEnd,
}: { }: {
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
scrollToEnd: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@ -75,14 +73,12 @@ export function MessageInput({
setMaxHeight(max) setMaxHeight(max)
setIsInputScrollable(availableSpace < 30) setIsInputScrollable(availableSpace < 30)
scrollToEnd()
}, },
[scrollToEnd, topInset], [topInset],
) )
return ( return (
<View style={[a.px_md, a.py_sm]}> <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
<View <View
style={[ style={[
a.w_full, a.w_full,

View File

@ -19,7 +19,6 @@ export function MessageInput({
onSendMessage, onSendMessage,
}: { }: {
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
scrollToEnd: () => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()

View File

@ -1,8 +1,8 @@
import React, {useCallback, useRef} from 'react' import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View} from 'react-native'
import Animated, { import Animated, {
dispatchCommand,
runOnJS, runOnJS,
scrollTo,
useAnimatedKeyboard, useAnimatedKeyboard,
useAnimatedReaction, useAnimatedReaction,
useAnimatedRef, useAnimatedRef,
@ -92,15 +92,14 @@ export function MessagesList({
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
// onStartReached to fire. // onStartReached to fire.
const contentHeight = useSharedValue(0) const prevContentHeight = useRef(0)
const prevItemCount = useRef(0) const prevItemCount = useRef(0)
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank const isDragging = useSharedValue(false)
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false)
const keyboardIsAnimating = useSharedValue(false)
const layoutHeight = useSharedValue(0) const layoutHeight = useSharedValue(0)
// -- Scroll handling
// Every time the content size changes, that means one of two things is happening: // Every time the content size changes, that means one of two things is happening:
// 1. New messages are being added from the log or from a message you have sent // 1. New messages are being added from the log or from a message you have sent
// 2. Old messages are being prepended to the top // 2. Old messages are being prepended to the top
@ -117,60 +116,142 @@ export function MessagesList({
// previous off whenever we add new content to the previous offset whenever we add new content to the list. // previous off whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.value && hasScrolled) { if (isWeb && isAtTop.value && hasScrolled) {
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: height - contentHeight.value, offset: height - prevContentHeight.current,
animated: false, animated: false,
}) })
} }
// This number _must_ be the height of the MaybeLoader component // This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) { if (height > 50 && isAtBottom.value) {
let newOffset = height
// If the size of the content is changing by more than the height of the screen, then we should only // If the size of the content is changing by more than the height of the screen, then we should only
// scroll 1 screen down, and let the user scroll the rest. However, because a single message could be // scroll 1 screen down, and let the user scroll the rest. However, because a single message could be
// really large - and the normal chat behavior would be to still scroll to the end if it's only one // really large - and the normal chat behavior would be to still scroll to the end if it's only one
// message - we ignore this rule if there's only one additional message // message - we ignore this rule if there's only one additional message
if ( if (
hasScrolled && hasScrolled &&
height - contentHeight.value > layoutHeight.value - 50 && height - prevContentHeight.current > layoutHeight.value - 50 &&
convoState.items.length - prevItemCount.current > 1 convoState.items.length - prevItemCount.current > 1
) { ) {
newOffset = contentHeight.value - 50
setShowNewMessagesPill(true)
} else if (!hasScrolled && !convoState.isFetchingHistory) {
setHasScrolled(true)
}
flatListRef.current?.scrollToOffset({ flatListRef.current?.scrollToOffset({
offset: newOffset, offset: height - layoutHeight.value + 50,
animated: hasScrolled, animated: hasScrolled,
}) })
isMomentumScrolling.value = true setShowNewMessagesPill(true)
} else {
flatListRef.current?.scrollToOffset({
offset: height,
animated: hasScrolled,
})
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
// because otherwise there is too much of a delay between the time the content
// scrolls and the time the screen appears, causing a flicker.
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
// is actually async itself - all the info has to come across the bridge first.
if (!hasScrolled && !convoState.isFetchingHistory) {
setTimeout(() => {
setHasScrolled(true)
}, 100)
} }
contentHeight.value = height }
}
prevContentHeight.current = height
prevItemCount.current = convoState.items.length prevItemCount.current = convoState.items.length
}, },
[ [
hasScrolled, hasScrolled,
convoState.items.length,
convoState.isFetchingHistory,
setHasScrolled, setHasScrolled,
// all of these are stable convoState.isFetchingHistory,
contentHeight, convoState.items.length,
// these are stable
flatListRef, flatListRef,
isAtBottom.value,
isAtTop.value, isAtTop.value,
isMomentumScrolling, isAtBottom.value,
keyboardIsAnimating.value,
layoutHeight.value, layoutHeight.value,
], ],
) )
const onBeginDrag = React.useCallback(() => {
'worklet'
isDragging.value = true
}, [isDragging])
const onEndDrag = React.useCallback(() => {
'worklet'
isDragging.value = false
}, [isDragging])
const onStartReached = useCallback(() => { const onStartReached = useCallback(() => {
if (hasScrolled) { if (hasScrolled) {
convoState.fetchMessageHistory() convoState.fetchMessageHistory()
} }
}, [convoState, hasScrolled]) }, [convoState, hasScrolled])
const onScroll = React.useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
layoutHeight.value = e.layoutMeasurement.height
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
if (
showNewMessagesPill &&
e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset
) {
runOnJS(setShowNewMessagesPill)(false)
}
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
// when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1
},
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
)
// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
const finalKeyboardHeight = useSharedValue(0)
// On web, we don't want to do anything.
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction(
() => animatedKeyboard.height.value,
(now, prev) => {
'worklet'
// This never applies on web
if (isWeb) {
return
}
// Only call this on every frame while _opening_ the keyboard
if (prev && now > 0 && now >= prev) {
dispatchCommand(flatListRef, 'scrollToEnd', [false])
}
// We want to store the full keyboard height after it fully opens so we can make some
// assumptions in onLayout
if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) {
finalKeyboardHeight.value = now
}
},
)
// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedStyle = useAnimatedStyle(() => ({
marginBottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
}))
// -- Message sending
const onSendMessage = useCallback( const onSendMessage = useCallback(
async (text: string) => { async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true}) let rt = new RichText({text}, {cleanNewlines: true})
@ -196,80 +277,36 @@ export function MessagesList({
[convoState, getAgent], [convoState, getAgent],
) )
const onScroll = React.useCallback( // Any time the List layout changes, we want to scroll to the bottom. This only happens whenever
(e: ReanimatedScrollEvent) => { // the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`.
'worklet' // This accounts for things like the emoji keyboard opening, changes in block state, etc.
layoutHeight.value = e.layoutMeasurement.height const onListLayout = React.useCallback(() => {
if (isDragging.value) return
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height const kh = animatedKeyboard.height.value
const fkh = finalKeyboardHeight.value
if ( // We only run the layout scroll if:
showNewMessagesPill && // - We're on web
e.contentSize.height - e.layoutMeasurement.height / 3 < bottomOffset // - The keyboard is not open. This accounts for changing block states
) { // - The final keyboard height has been initially set and the keyboard height is greater than that
runOnJS(setShowNewMessagesPill)(false) if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) {
flatListRef.current?.scrollToEnd({animated: true})
} }
}, [
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom flatListRef,
// when a new message is added, hence the 100 pixel offset finalKeyboardHeight.value,
isAtBottom.value = e.contentSize.height - 100 < bottomOffset animatedKeyboard.height.value,
isAtTop.value = e.contentOffset.y <= 1 isDragging.value,
}, ])
[layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
)
// This tells us when we are no longer scrolling
const onMomentumEnd = React.useCallback(() => {
'worklet'
isMomentumScrolling.value = false
}, [isMomentumScrolling])
const scrollToEndNow = React.useCallback(() => {
if (isMomentumScrolling.value) return
flatListRef.current?.scrollToEnd({animated: false})
}, [flatListRef, isMomentumScrolling.value])
// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
// On web, we don't want to do anything.
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
// on the UI thread - directly calling `scrollTo` on the underlying native component, so we achieve 60 FPS.
useAnimatedReaction(
() => animatedKeyboard.height.value,
(now, prev) => {
'worklet'
// This never applies on web
if (isWeb) {
keyboardIsAnimating.value = false
return
}
// We only need to scroll to end while the keyboard is _opening_. During close, the position changes as we
// "expand" the view.
if (prev && now > prev) {
scrollTo(flatListRef, 0, contentHeight.value + now, false)
}
keyboardIsAnimating.value = Boolean(prev) && now !== prev
},
)
// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our
// `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedStyle = useAnimatedStyle(() => ({
marginBottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
}))
return ( return (
<Animated.View style={[a.flex_1, animatedStyle]}> <Animated.View style={[a.flex_1, animatedStyle]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}> <ScrollProvider
onScroll={onScroll}
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}>
<List <List
ref={flatListRef} ref={flatListRef}
data={convoState.items} data={convoState.items}
@ -288,6 +325,7 @@ export function MessagesList({
removeClippedSubviews={false} removeClippedSubviews={false}
sideBorders={false} sideBorders={false}
onContentSizeChange={onContentSizeChange} onContentSizeChange={onContentSizeChange}
onLayout={onListLayout}
onStartReached={onStartReached} onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed} onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100} scrollEventThrottle={100}
@ -301,10 +339,7 @@ export function MessagesList({
{convoState.status === ConvoStatus.Disabled ? ( {convoState.status === ConvoStatus.Disabled ? (
<ChatDisabled /> <ChatDisabled />
) : ( ) : (
<MessageInput <MessageInput onSendMessage={onSendMessage} />
onSendMessage={onSendMessage}
scrollToEnd={scrollToEndNow}
/>
)} )}
</> </>
) : ( ) : (