about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx8
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx1
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx183
3 files changed, 111 insertions, 81 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index 48c3aeb37..9deecfd49 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 (
-    <View style={[a.px_md, a.py_sm]}>
+    <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
       <View
         style={[
           a.w_full,
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
index ba41cc23c..dd7c4f685 100644
--- a/src/screens/Messages/Conversation/MessageInput.web.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -19,7 +19,6 @@ export function MessageInput({
   onSendMessage,
 }: {
   onSendMessage: (message: string) => 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 1f9147c57..f099e267a 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 (
     <Animated.View style={[a.flex_1, animatedStyle]}>
       {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
-      <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
+      <ScrollProvider
+        onScroll={onScroll}
+        onBeginDrag={onBeginDrag}
+        onEndDrag={onEndDrag}>
         <List
           ref={flatListRef}
           data={convoState.items}
@@ -288,6 +325,7 @@ export function MessagesList({
           removeClippedSubviews={false}
           sideBorders={false}
           onContentSizeChange={onContentSizeChange}
+          onLayout={onListLayout}
           onStartReached={onStartReached}
           onScrollToIndexFailed={onScrollToIndexFailed}
           scrollEventThrottle={100}
@@ -301,10 +339,7 @@ export function MessagesList({
           {convoState.status === ConvoStatus.Disabled ? (
             <ChatDisabled />
           ) : (
-            <MessageInput
-              onSendMessage={onSendMessage}
-              scrollToEnd={scrollToEndNow}
-            />
+            <MessageInput onSendMessage={onSendMessage} />
           )}
         </>
       ) : (