diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 48c3aeb37c..9deecfd49e 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -27,10 +27,8 @@ import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/ico export function MessageInput({ onSendMessage, - scrollToEnd, }: { onSendMessage: (message: string) => void - scrollToEnd: () => void }) { const {_} = useLingui() const t = useTheme() @@ -75,14 +73,12 @@ export function MessageInput({ setMaxHeight(max) setIsInputScrollable(availableSpace < 30) - - scrollToEnd() }, - [scrollToEnd, topInset], + [topInset], ) return ( - + void - scrollToEnd: () => void }) { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index 1f9147c576..f099e267a1 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -1,8 +1,8 @@ import React, {useCallback, useRef} from 'react' import {FlatList, View} from 'react-native' import Animated, { + dispatchCommand, runOnJS, - scrollTo, useAnimatedKeyboard, useAnimatedReaction, useAnimatedRef, @@ -92,15 +92,14 @@ export function MessagesList({ // 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 prevContentHeight = useRef(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 isDragging = useSharedValue(false) const layoutHeight = useSharedValue(0) + // -- Scroll handling + // 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 @@ -117,85 +116,78 @@ export function MessagesList({ // 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, + offset: height - prevContentHeight.current, animated: false, }) } // This number _must_ be the height of the MaybeLoader component - if (height > 50 && isAtBottom.value && !keyboardIsAnimating.value) { - let newOffset = height + if (height > 50 && isAtBottom.value) { // 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 && + height - prevContentHeight.current > layoutHeight.value - 50 && convoState.items.length - prevItemCount.current > 1 ) { - newOffset = contentHeight.value - 50 + flatListRef.current?.scrollToOffset({ + offset: height - layoutHeight.value + 50, + animated: hasScrolled, + }) setShowNewMessagesPill(true) - } else if (!hasScrolled && !convoState.isFetchingHistory) { - setHasScrolled(true) - } + } else { + flatListRef.current?.scrollToOffset({ + offset: height, + animated: hasScrolled, + }) - flatListRef.current?.scrollToOffset({ - offset: newOffset, - animated: hasScrolled, - }) - isMomentumScrolling.value = true + // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, + // because otherwise there is too much of a delay between the time the content + // scrolls and the time the screen appears, causing a flicker. + // We cannot actually use a synchronous scroll here, because `onContentSizeChange` + // is actually async itself - all the info has to come across the bridge first. + if (!hasScrolled && !convoState.isFetchingHistory) { + setTimeout(() => { + setHasScrolled(true) + }, 100) + } + } } - contentHeight.value = height + + prevContentHeight.current = height prevItemCount.current = convoState.items.length }, [ hasScrolled, - convoState.items.length, - convoState.isFetchingHistory, setHasScrolled, - // all of these are stable - contentHeight, + convoState.isFetchingHistory, + convoState.items.length, + // these are stable flatListRef, - isAtBottom.value, isAtTop.value, - isMomentumScrolling, - keyboardIsAnimating.value, + isAtBottom.value, layoutHeight.value, ], ) + const onBeginDrag = React.useCallback(() => { + 'worklet' + isDragging.value = true + }, [isDragging]) + + const onEndDrag = React.useCallback(() => { + 'worklet' + isDragging.value = false + }, [isDragging]) + const onStartReached = useCallback(() => { if (hasScrolled) { convoState.fetchMessageHistory() } }, [convoState, 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 - }) - - convoState.sendMessage({ - text: rt.text, - facets: rt.facets, - }) - }, - [convoState, getAgent], - ) - const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { 'worklet' @@ -218,22 +210,12 @@ export function MessagesList({ [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 {bottom: bottomInset} = useSafeAreaInsets() const nativeBottomBarHeight = isIOS ? 42 : 60 const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight + const finalKeyboardHeight = useSharedValue(0) // 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 @@ -244,16 +226,19 @@ export function MessagesList({ '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) + // Only call this on every frame while _opening_ the keyboard + if (prev && now > 0 && now >= prev) { + dispatchCommand(flatListRef, 'scrollToEnd', [false]) + } + + // We want to store the full keyboard height after it fully opens so we can make some + // assumptions in onLayout + if (finalKeyboardHeight.value === 0 && prev && now > 0 && now === prev) { + finalKeyboardHeight.value = now } - keyboardIsAnimating.value = Boolean(prev) && now !== prev }, ) @@ -266,10 +251,62 @@ export function MessagesList({ : bottomOffset, })) + // -- Message sending + 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 + }) + + convoState.sendMessage({ + text: rt.text, + facets: rt.facets, + }) + }, + [convoState, getAgent], + ) + + // Any time the List layout changes, we want to scroll to the bottom. This only happens whenever + // the _lists_ size changes, _not_ the content size which is handled by `onContentSizeChange`. + // This accounts for things like the emoji keyboard opening, changes in block state, etc. + const onListLayout = React.useCallback(() => { + if (isDragging.value) return + + const kh = animatedKeyboard.height.value + const fkh = finalKeyboardHeight.value + + // We only run the layout scroll if: + // - We're on web + // - The keyboard is not open. This accounts for changing block states + // - The final keyboard height has been initially set and the keyboard height is greater than that + if (isWeb || kh === 0 || (fkh > 0 && kh >= fkh)) { + flatListRef.current?.scrollToEnd({animated: true}) + } + }, [ + flatListRef, + finalKeyboardHeight.value, + animatedKeyboard.height.value, + isDragging.value, + ]) + return ( {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - + ) : ( - + )} ) : (