import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' import Animated, { runOnJS, scrollTo, useAnimatedKeyboard, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, 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 {AppBskyRichtextFacet, RichText} from '@atproto/api' import {shortenLinks} from '#/lib/strings/rich-text-manip' import {isIOS, 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, useBreakpoints} from '#/alf' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' 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') { 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({ hasScrolled, setHasScrolled, }: { hasScrolled: boolean setHasScrolled: React.Dispatch> }) { const convo = useConvoActive() const {getAgent} = useAgent() const flatListRef = useAnimatedRef() const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false) // 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) 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 // 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) // 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 off whenever we add new content to the previous offset whenever we add new content to the list. if (isWeb && isAtTop.value && hasScrolled) { flatListRef.current?.scrollToOffset({ offset: height - contentHeight.value, animated: false, }) } // This number _must_ be the height of the MaybeLoader component if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) { let newOffset = height // 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 // 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 if ( hasScrolled && height - contentHeight.value > layoutHeight.value - 50 && convo.items.length - prevItemCount.current > 1 ) { newOffset = contentHeight.value - 50 setShowNewMessagesPill(true) } else if (!hasScrolled && !convo.isFetchingHistory) { setHasScrolled(true) } flatListRef.current?.scrollToOffset({ offset: newOffset, animated: hasScrolled, }) isMomentumScrolling.value = true } contentHeight.value = height prevItemCount.current = convo.items.length }, [ hasScrolled, convo.items.length, convo.isFetchingHistory, setHasScrolled, // all of these are stable contentHeight, flatListRef, isAtBottom.value, isAtTop.value, isMomentumScrolling, keyboardIsAnimating.value, layoutHeight.value, ], ) const onStartReached = useCallback(() => { if (hasScrolled) { convo.fetchMessageHistory() } }, [convo, hasScrolled]) 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' 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], ) // 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 {gtMobile} = useBreakpoints() const {bottom: bottomInset} = useSafeAreaInsets() const nativeBottomBarHeight = isIOS ? 42 : 60 const bottomOffset = isWeb && gtMobile ? 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 ( {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} } /> {showNewMessagesPill && } ) }