import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' import {useKeyboardHandler} from 'react-native-keyboard-controller' import {runOnJS, useSharedValue} from 'react-native-reanimated' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {isNative} from '#/platform/detection' import {useConvoActive} from '#/state/messages/convo' import {ConvoItem} from '#/state/messages/convo/types' import {useAgent} from '#/state/session' 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 {atoms as a} from '#/alf' import {MessageItem} from '#/components/dms/MessageItem' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' function MaybeLoader({isLoading}: {isLoading: boolean}) { return ( {isLoading && } ) } function renderItem({item}: {item: ConvoItem}) { if (item.type === 'message' || item.type === 'pending-message') { return } else if (item.type === 'deleted-message') { return Deleted message } else if (item.type === 'error-recoverable') { return } return null } function keyExtractor(item: ConvoItem) { return item.key } function onScrollToIndexFailed() { // Placeholder function. You have to give FlatList something or else it will error. } export function MessagesList() { const convo = useConvoActive() const {getAgent} = useAgent() const flatListRef = useRef(null) // 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) // This will be used on web to assist in determing if we need to maintain the content offset const isAtTop = useSharedValue(true) // 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) // 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. const isMomentumScrolling = useSharedValue(false) const hasInitiallyScrolled = useSharedValue(false) // 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) => { // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the // previous offset whenever we add new content to the previous offset whenever we add new content to the list. if (isWeb && isAtTop.value && hasInitiallyScrolled.value) { flatListRef.current?.scrollToOffset({ animated: false, offset: height - contentHeight.value, }) } contentHeight.value = height // This number _must_ be the height of the MaybeLoader component if (height <= 50 || !isAtBottom.value) { return } flatListRef.current?.scrollToOffset({ animated: hasInitiallyScrolled.value, offset: height, }) isMomentumScrolling.value = true }, [ contentHeight, hasInitiallyScrolled, isAtBottom.value, isAtTop.value, isMomentumScrolling, ], ) // 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 (hasInitiallyScrolled.value) { convo.fetchMessageHistory() } }, [convo, hasInitiallyScrolled]) const onSendMessage = useCallback( async (text: string) => { let rt = new RichText({text}, {cleanNewlines: true}) await rt.detectFacets(getAgent()) rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user rt.facets = rt.facets?.filter(facet => { const mention = facet.features.find(feature => AppBskyRichtextFacet.isMention(feature), ) if (mention && !mention.did) { return false } return true }) convo.sendMessage({ text: rt.text, facets: rt.facets, }) }, [convo, getAgent], ) const onScroll = React.useCallback( (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 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 } }, [contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop], ) const onMomentumEnd = React.useCallback(() => { 'worklet' isMomentumScrolling.value = false }, [isMomentumScrolling]) const scrollToEnd = React.useCallback(() => { requestAnimationFrame(() => { if (isMomentumScrolling.value) return flatListRef.current?.scrollToEnd({animated: true}) isMomentumScrolling.value = true }) }, [isMomentumScrolling]) // This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly. const scrollToEndNow = React.useCallback(() => { flatListRef.current?.scrollToEnd({animated: false}) }, []) useKeyboardHandler({ onMove: () => { 'worklet' runOnJS(scrollToEndNow)() }, }) return ( <> {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} } /> ) }