|
|
|
@@ -1,12 +1,8 @@
|
|
|
|
|
import React, {useCallback, useRef} from 'react'
|
|
|
|
|
import {
|
|
|
|
|
FlatList,
|
|
|
|
|
NativeScrollEvent,
|
|
|
|
|
NativeSyntheticEvent,
|
|
|
|
|
Platform,
|
|
|
|
|
View,
|
|
|
|
|
} from 'react-native'
|
|
|
|
|
import {FlatList, Platform, View} from 'react-native'
|
|
|
|
|
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
|
|
|
|
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
|
|
|
|
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
|
|
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
|
|
|
import {msg, Trans} from '@lingui/macro'
|
|
|
|
|
import {useLingui} from '@lingui/react'
|
|
|
|
@@ -14,8 +10,12 @@ import {useLingui} from '@lingui/react'
|
|
|
|
|
import {isIOS} from '#/platform/detection'
|
|
|
|
|
import {useChat} from '#/state/messages'
|
|
|
|
|
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
|
|
|
|
|
import {ScrollProvider} from 'lib/ScrollContext'
|
|
|
|
|
import {isWeb} from 'platform/detection'
|
|
|
|
|
import {List} from 'view/com/util/List'
|
|
|
|
|
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
|
|
|
|
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
|
|
|
|
|
import {useScrollToEndOnFocus} from '#/screens/Messages/Conversation/useScrollToEndOnFocus'
|
|
|
|
|
import {atoms as a, useBreakpoints} from '#/alf'
|
|
|
|
|
import {Button, ButtonText} from '#/components/Button'
|
|
|
|
|
import {MessageItem} from '#/components/dms/MessageItem'
|
|
|
|
@@ -79,36 +79,64 @@ function keyExtractor(item: ConvoItem) {
|
|
|
|
|
return item.key
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onScrollToEndFailed() {
|
|
|
|
|
function onScrollToIndexFailed() {
|
|
|
|
|
// Placeholder function. You have to give FlatList something or else it will error.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MessagesList() {
|
|
|
|
|
const chat = useChat()
|
|
|
|
|
const flatListRef = useRef<FlatList>(null)
|
|
|
|
|
// We use this to know if we should scroll after a new clop is added to the list
|
|
|
|
|
const isAtBottom = useRef(false)
|
|
|
|
|
const currentOffset = React.useRef(0)
|
|
|
|
|
|
|
|
|
|
const onContentSizeChange = useCallback(() => {
|
|
|
|
|
if (currentOffset.current <= 100) {
|
|
|
|
|
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
|
|
|
|
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
|
|
|
|
// the bottom.
|
|
|
|
|
const isAtBottom = useSharedValue(true)
|
|
|
|
|
|
|
|
|
|
const onEndReached = useCallback(() => {
|
|
|
|
|
if (chat.status === ConvoStatus.Ready) {
|
|
|
|
|
// 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.
|
|
|
|
|
const contentHeight = useSharedValue(0)
|
|
|
|
|
|
|
|
|
|
const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
|
|
|
|
|
|
|
|
|
|
// This is only used on native because `Keyboard` can't be imported on web. On web, an input focus will immediately
|
|
|
|
|
// trigger scrolling to the bottom. On native however, we need to wait for the keyboard to present before scrolling,
|
|
|
|
|
// which is what this hook listens for
|
|
|
|
|
useScrollToEndOnFocus(flatListRef)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// 2. Old messages are being prepended to the top
|
|
|
|
|
//
|
|
|
|
|
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
|
|
|
|
|
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
|
|
|
|
|
//
|
|
|
|
|
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
|
|
|
|
|
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
|
|
|
|
|
// we will not scroll whenever new items get prepended to the top.
|
|
|
|
|
const onContentSizeChange = useCallback(
|
|
|
|
|
(_: number, height: number) => {
|
|
|
|
|
contentHeight.value = height
|
|
|
|
|
|
|
|
|
|
// This number _must_ be the height of the MaybeLoader component
|
|
|
|
|
if (height <= 50 || !isAtBottom.value) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flatListRef.current?.scrollToOffset({
|
|
|
|
|
animated: hasInitiallyScrolled,
|
|
|
|
|
offset: height,
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
[contentHeight, hasInitiallyScrolled, isAtBottom.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(() => {
|
|
|
|
|
if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
|
|
|
|
|
chat.fetchMessageHistory()
|
|
|
|
|
}
|
|
|
|
|
}, [chat])
|
|
|
|
|
|
|
|
|
|
const onInputFocus = useCallback(() => {
|
|
|
|
|
if (!isAtBottom.current) {
|
|
|
|
|
flatListRef.current?.scrollToOffset({offset: 0, animated: true})
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const onInputBlur = useCallback(() => {}, [])
|
|
|
|
|
}, [chat, hasInitiallyScrolled])
|
|
|
|
|
|
|
|
|
|
const onSendMessage = useCallback(
|
|
|
|
|
(text: string) => {
|
|
|
|
@@ -122,12 +150,28 @@ export function MessagesList() {
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const onScroll = React.useCallback(
|
|
|
|
|
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
|
|
|
currentOffset.current = e.nativeEvent.contentOffset.y
|
|
|
|
|
(e: ReanimatedScrollEvent) => {
|
|
|
|
|
'worklet'
|
|
|
|
|
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
runOnJS(setHasInitiallyScrolled)(true)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
[contentHeight.value, hasInitiallyScrolled, isAtBottom],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const onInputFocus = React.useCallback(() => {
|
|
|
|
|
flatListRef.current?.scrollToEnd({animated: true})
|
|
|
|
|
}, [flatListRef])
|
|
|
|
|
|
|
|
|
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
|
|
|
|
const {gtMobile} = useBreakpoints()
|
|
|
|
|
const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
|
|
|
|
@@ -139,42 +183,41 @@ export function MessagesList() {
|
|
|
|
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
|
|
|
|
behavior="padding"
|
|
|
|
|
contentContainerStyle={a.flex_1}>
|
|
|
|
|
<FlatList
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
data={chat.status === ConvoStatus.Ready ? chat.items : undefined}
|
|
|
|
|
keyExtractor={keyExtractor}
|
|
|
|
|
renderItem={renderItem}
|
|
|
|
|
contentContainerStyle={{paddingHorizontal: 10}}
|
|
|
|
|
// In the future, we might want to adjust this value. Not very concerning right now as long as we are only
|
|
|
|
|
// dealing with text. But whenever we have images or other media and things are taller, we will want to lower
|
|
|
|
|
// this...probably.
|
|
|
|
|
initialNumToRender={20}
|
|
|
|
|
// Same with the max to render per batch. Let's be safe for now though.
|
|
|
|
|
maxToRenderPerBatch={25}
|
|
|
|
|
inverted={true}
|
|
|
|
|
onEndReached={onEndReached}
|
|
|
|
|
onScrollToIndexFailed={onScrollToEndFailed}
|
|
|
|
|
onContentSizeChange={onContentSizeChange}
|
|
|
|
|
onScroll={onScroll}
|
|
|
|
|
// We don't really need to call this much since there are not any animations that rely on this
|
|
|
|
|
scrollEventThrottle={100}
|
|
|
|
|
maintainVisibleContentPosition={{
|
|
|
|
|
minIndexForVisible: 1,
|
|
|
|
|
}}
|
|
|
|
|
ListFooterComponent={
|
|
|
|
|
<MaybeLoader
|
|
|
|
|
isLoading={
|
|
|
|
|
chat.status === ConvoStatus.Ready && chat.isFetchingHistory
|
|
|
|
|
{/* This view keeps the scroll bar and content within the CenterView on web, otherwise the entire window would scroll */}
|
|
|
|
|
{/* @ts-expect-error web only */}
|
|
|
|
|
<View style={[{flex: 1}, isWeb && {'overflow-y': 'scroll'}]}>
|
|
|
|
|
{/* Custom scroll provider so we can use the `onScroll` event in our custom List implementation */}
|
|
|
|
|
<ScrollProvider onScroll={onScroll}>
|
|
|
|
|
<List
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
data={chat.status === ConvoStatus.Ready ? chat.items : undefined}
|
|
|
|
|
renderItem={renderItem}
|
|
|
|
|
keyExtractor={keyExtractor}
|
|
|
|
|
disableVirtualization={true}
|
|
|
|
|
initialNumToRender={isWeb ? 50 : 25}
|
|
|
|
|
maxToRenderPerBatch={isWeb ? 50 : 25}
|
|
|
|
|
keyboardDismissMode="on-drag"
|
|
|
|
|
maintainVisibleContentPosition={{
|
|
|
|
|
minIndexForVisible: 1,
|
|
|
|
|
}}
|
|
|
|
|
removeClippedSubviews={false}
|
|
|
|
|
onContentSizeChange={onContentSizeChange}
|
|
|
|
|
onStartReached={onStartReached}
|
|
|
|
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
|
|
|
scrollEventThrottle={100}
|
|
|
|
|
ListHeaderComponent={
|
|
|
|
|
<MaybeLoader
|
|
|
|
|
isLoading={
|
|
|
|
|
chat.status === ConvoStatus.Ready && chat.isFetchingHistory
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
removeClippedSubviews={true}
|
|
|
|
|
keyboardDismissMode="on-drag"
|
|
|
|
|
/>
|
|
|
|
|
</ScrollProvider>
|
|
|
|
|
</View>
|
|
|
|
|
<MessageInput
|
|
|
|
|
onSendMessage={onSendMessage}
|
|
|
|
|
onFocus={onInputFocus}
|
|
|
|
|
onBlur={onInputBlur}
|
|
|
|
|
onFocus={isWeb ? onInputFocus : undefined}
|
|
|
|
|
/>
|
|
|
|
|
</KeyboardAvoidingView>
|
|
|
|
|
)
|
|
|
|
|