[🐴] 🤞 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
parent
2eaecfcaa4
commit
5343910570
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
flatListRef.current?.scrollToOffset({
|
||||||
|
offset: height - layoutHeight.value + 50,
|
||||||
|
animated: hasScrolled,
|
||||||
|
})
|
||||||
setShowNewMessagesPill(true)
|
setShowNewMessagesPill(true)
|
||||||
} else if (!hasScrolled && !convoState.isFetchingHistory) {
|
} else {
|
||||||
setHasScrolled(true)
|
flatListRef.current?.scrollToOffset({
|
||||||
}
|
offset: height,
|
||||||
|
animated: hasScrolled,
|
||||||
|
})
|
||||||
|
|
||||||
flatListRef.current?.scrollToOffset({
|
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
|
||||||
offset: newOffset,
|
// because otherwise there is too much of a delay between the time the content
|
||||||
animated: hasScrolled,
|
// scrolls and the time the screen appears, causing a flicker.
|
||||||
})
|
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
|
||||||
isMomentumScrolling.value = true
|
// 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
|
}, [
|
||||||
// when a new message is added, hence the 100 pixel offset
|
flatListRef,
|
||||||
isAtBottom.value = e.contentSize.height - 100 < bottomOffset
|
finalKeyboardHeight.value,
|
||||||
isAtTop.value = e.contentOffset.y <= 1
|
animatedKeyboard.height.value,
|
||||||
},
|
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}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
Loading…
Reference in New Issue