[🐴] Additional tweaks to the message list (#4075)

* more cleanup and little fixes

another nit

nit

small annoyance

add a comment

only use `scrollTo` when necessary

remove now unnecessary styles

* move `setHasScrolled` to `onContentSizeChanged`

* account for block footer
zio/stable
Hailey 2024-05-17 08:21:35 -07:00 committed by GitHub
parent 829f6a9e64
commit dd4c8d8e4f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 57 additions and 85 deletions

View File

@ -2,7 +2,6 @@ import React, {useCallback, useRef} from 'react'
import {FlatList, View} from 'react-native' import {FlatList, View} from 'react-native'
import Animated, { import Animated, {
runOnJS, runOnJS,
runOnUI,
scrollTo, scrollTo,
useAnimatedKeyboard, useAnimatedKeyboard,
useAnimatedReaction, useAnimatedReaction,
@ -24,7 +23,7 @@ import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput' import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError' import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {MessageItem} from '#/components/dms/MessageItem' import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
@ -64,8 +63,13 @@ function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error. // Placeholder function. You have to give FlatList something or else it will error.
} }
export function MessagesList() { export function MessagesList({
const t = useTheme() hasScrolled,
setHasScrolled,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
}) {
const convo = useConvoActive() const convo = useConvoActive()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const flatListRef = useAnimatedRef<FlatList>() const flatListRef = useAnimatedRef<FlatList>()
@ -88,22 +92,9 @@ export function MessagesList() {
// We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
// Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not. // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
const isMomentumScrolling = useSharedValue(false) const isMomentumScrolling = useSharedValue(false)
const hasInitiallyScrolled = useSharedValue(false)
const keyboardIsAnimating = useSharedValue(false) const keyboardIsAnimating = useSharedValue(false)
const layoutHeight = useSharedValue(0) const layoutHeight = useSharedValue(0)
// Since we are using the native web methods for scrolling on `List`, we only use the reanimated `scrollTo` on native
const scrollToOffset = React.useCallback(
(offset: number, animated: boolean) => {
if (isWeb) {
flatListRef.current?.scrollToOffset({offset, animated})
} else {
runOnUI(scrollTo)(flatListRef, 0, offset, animated)
}
},
[flatListRef],
)
// 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
@ -118,8 +109,11 @@ export function MessagesList() {
(_: number, height: number) => { (_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// 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 && hasInitiallyScrolled.value) { if (isWeb && isAtTop.value && hasScrolled) {
scrollToOffset(height - contentHeight.value, false) flatListRef.current?.scrollToOffset({
offset: height - contentHeight.value,
animated: false,
})
} }
// This number _must_ be the height of the MaybeLoader component // This number _must_ be the height of the MaybeLoader component
@ -130,40 +124,46 @@ export function MessagesList() {
// 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 (
hasInitiallyScrolled.value && hasScrolled &&
height - contentHeight.value > layoutHeight.value - 50 && height - contentHeight.value > layoutHeight.value - 50 &&
convo.items.length - prevItemCount.current > 1 convo.items.length - prevItemCount.current > 1
) { ) {
newOffset = contentHeight.value - 50 newOffset = contentHeight.value - 50
setShowNewMessagesPill(true) setShowNewMessagesPill(true)
} else if (!hasScrolled && !convo.isFetchingHistory) {
setHasScrolled(true)
} }
scrollToOffset(newOffset, hasInitiallyScrolled.value)
flatListRef.current?.scrollToOffset({
offset: newOffset,
animated: hasScrolled,
})
isMomentumScrolling.value = true isMomentumScrolling.value = true
} }
contentHeight.value = height contentHeight.value = height
prevItemCount.current = convo.items.length prevItemCount.current = convo.items.length
}, },
[ [
contentHeight, hasScrolled,
scrollToOffset,
isMomentumScrolling,
convo.items.length, convo.items.length,
// All of these are stable convo.isFetchingHistory,
setHasScrolled,
// all of these are stable
contentHeight,
flatListRef,
isAtBottom.value, isAtBottom.value,
isAtTop.value,
isMomentumScrolling,
keyboardIsAnimating.value, keyboardIsAnimating.value,
layoutHeight.value, layoutHeight.value,
hasInitiallyScrolled.value,
isAtTop.value,
], ],
) )
// The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
// immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
const onStartReached = useCallback(() => { const onStartReached = useCallback(() => {
if (hasInitiallyScrolled.value) { if (hasScrolled) {
convo.fetchMessageHistory() convo.fetchMessageHistory()
} }
}, [convo, hasInitiallyScrolled]) }, [convo, hasScrolled])
const onSendMessage = useCallback( const onSendMessage = useCallback(
async (text: string) => { async (text: string) => {
@ -208,34 +208,20 @@ export function MessagesList() {
// when a new message is added, hence the 100 pixel offset // when a new message is added, hence the 100 pixel offset
isAtBottom.value = e.contentSize.height - 100 < bottomOffset isAtBottom.value = e.contentSize.height - 100 < bottomOffset
isAtTop.value = e.contentOffset.y <= 1 isAtTop.value = e.contentOffset.y <= 1
// This number _must_ be the height of the MaybeLoader component.
// We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
// adds a 50 pixel offset.
if (contentHeight.value > 50 && !hasInitiallyScrolled.value) {
hasInitiallyScrolled.value = true
}
}, },
[ [layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
layoutHeight,
showNewMessagesPill,
isAtBottom,
isAtTop,
hasInitiallyScrolled,
contentHeight.value,
],
) )
// This tells us when we are no longer scrolling
const onMomentumEnd = React.useCallback(() => { const onMomentumEnd = React.useCallback(() => {
'worklet' 'worklet'
isMomentumScrolling.value = false isMomentumScrolling.value = false
}, [isMomentumScrolling]) }, [isMomentumScrolling])
const scrollToEnd = React.useCallback(() => { const scrollToEndNow = React.useCallback(() => {
if (isMomentumScrolling.value) return if (isMomentumScrolling.value) return
scrollToOffset(contentHeight.value, true) flatListRef.current?.scrollToEnd({animated: false})
isMomentumScrolling.value = true }, [flatListRef, isMomentumScrolling.value])
}, [contentHeight.value, isMomentumScrolling, scrollToOffset])
// -- Keyboard animation handling // -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard() const animatedKeyboard = useAnimatedKeyboard()
@ -269,24 +255,15 @@ export function MessagesList() {
// This changes the size of the `ListFooterComponent`. Whenever this changes, the content size will change and our // 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. // `onContentSizeChange` function will handle scrolling to the appropriate offset.
const animatedFooterStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
marginBottom: marginBottom:
animatedKeyboard.height.value > bottomOffset animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value ? animatedKeyboard.height.value
: bottomOffset, : bottomOffset,
})) }))
// At a minimum we want the bottom to be whatever the height of our insets and bottom bar is. If the keyboard's height
// is greater than that however, we use that value.
const animatedInputStyle = useAnimatedStyle(() => ({
bottom:
animatedKeyboard.height.value > bottomOffset
? animatedKeyboard.height.value
: bottomOffset,
}))
return ( return (
<> <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} onMomentumEnd={onMomentumEnd}>
<List <List
@ -314,13 +291,13 @@ export function MessagesList() {
ListHeaderComponent={ ListHeaderComponent={
<MaybeLoader isLoading={convo.isFetchingHistory} /> <MaybeLoader isLoading={convo.isFetchingHistory} />
} }
ListFooterComponent={<Animated.View style={[animatedFooterStyle]} />}
/> />
</ScrollProvider> </ScrollProvider>
<MessageInput
onSendMessage={onSendMessage}
scrollToEnd={scrollToEndNow}
/>
{showNewMessagesPill && <NewMessagesPill />} {showNewMessagesPill && <NewMessagesPill />}
<Animated.View style={[a.relative, t.atoms.bg, animatedInputStyle]}> </Animated.View>
<MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
</Animated.View>
</>
) )
} }

View File

@ -71,23 +71,15 @@ function Inner() {
const convoState = useConvo() const convoState = useConvo()
const {_} = useLingui() const {_} = useLingui()
const [hasInitiallyRendered, setHasInitiallyRendered] = React.useState(false) // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
// we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
// HACK: Because we need to scroll to the bottom of the list once initial items are added to the list, we also have // empty. So, we also check for that possible state as well and render once we can.
// to take into account that scrolling to the end of the list on native will happen asynchronously. This will cause const [hasScrolled, setHasScrolled] = React.useState(false)
// a little flicker when the items are first renedered at the top and immediately scrolled to the bottom. to prevent const readyToShow =
// this, we will wait until the first render has completed to remove the loading overlay. hasScrolled ||
React.useEffect(() => { (convoState.status === ConvoStatus.Ready &&
if ( !convoState.isFetchingHistory &&
!hasInitiallyRendered && convoState.items.length === 0)
isConvoActive(convoState) &&
!convoState.isFetchingHistory
) {
setTimeout(() => {
setHasInitiallyRendered(true)
}, 15)
}
}, [convoState, hasInitiallyRendered])
if (convoState.status === ConvoStatus.Error) { if (convoState.status === ConvoStatus.Error) {
return ( return (
@ -110,11 +102,14 @@ function Inner() {
<Header profile={convoState.recipients?.[0]} /> <Header profile={convoState.recipients?.[0]} />
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
{isConvoActive(convoState) ? ( {isConvoActive(convoState) ? (
<MessagesList /> <MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
/>
) : ( ) : (
<ListMaybePlaceholder isLoading /> <ListMaybePlaceholder isLoading />
)} )}
{!hasInitiallyRendered && ( {!readyToShow && (
<View <View
style={[ style={[
a.absolute, a.absolute,