[🐴] 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 footerzio/stable
parent
829f6a9e64
commit
dd4c8d8e4f
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue