about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx45
-rw-r--r--src/components/dms/ActionsWrapper.tsx74
-rw-r--r--src/components/dms/MessagesListHeader.tsx3
-rw-r--r--src/components/dms/NewMessagesPill.tsx100
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx3
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx199
-rw-r--r--src/screens/Messages/Conversation/index.tsx8
-rw-r--r--src/view/com/composer/Composer.tsx331
8 files changed, 410 insertions, 353 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 425d6ac6e..7c60d1624 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -4,6 +4,7 @@ import 'view/icons'
 
 import React, {useEffect, useState} from 'react'
 import {GestureHandlerRootView} from 'react-native-gesture-handler'
+import {KeyboardProvider} from 'react-native-keyboard-controller'
 import {RootSiblingParent} from 'react-native-root-siblings'
 import {
   initialWindowMetrics,
@@ -142,27 +143,29 @@ function App() {
    * that is set up in the InnerApp component above.
    */
   return (
-    <SessionProvider>
-      <ShellStateProvider>
-        <PrefsStateProvider>
-          <MutedThreadsProvider>
-            <InvitesStateProvider>
-              <ModalStateProvider>
-                <DialogStateProvider>
-                  <LightboxStateProvider>
-                    <I18nProvider>
-                      <PortalProvider>
-                        <InnerApp />
-                      </PortalProvider>
-                    </I18nProvider>
-                  </LightboxStateProvider>
-                </DialogStateProvider>
-              </ModalStateProvider>
-            </InvitesStateProvider>
-          </MutedThreadsProvider>
-        </PrefsStateProvider>
-      </ShellStateProvider>
-    </SessionProvider>
+    <KeyboardProvider enabled={true}>
+      <SessionProvider>
+        <ShellStateProvider>
+          <PrefsStateProvider>
+            <MutedThreadsProvider>
+              <InvitesStateProvider>
+                <ModalStateProvider>
+                  <DialogStateProvider>
+                    <LightboxStateProvider>
+                      <I18nProvider>
+                        <PortalProvider>
+                          <InnerApp />
+                        </PortalProvider>
+                      </I18nProvider>
+                    </LightboxStateProvider>
+                  </DialogStateProvider>
+                </ModalStateProvider>
+              </InvitesStateProvider>
+            </MutedThreadsProvider>
+          </PrefsStateProvider>
+        </ShellStateProvider>
+      </SessionProvider>
+    </KeyboardProvider>
   )
 }
 
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index 3b9a56bdc..a349c3cfa 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
-import {Keyboard, Pressable, View} from 'react-native'
+import {Keyboard} from 'react-native'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
 import Animated, {
   cancelAnimation,
   runOnJS,
@@ -15,8 +16,6 @@ import {atoms as a} from '#/alf'
 import {MessageMenu} from '#/components/dms/MessageMenu'
 import {useMenuControl} from '#/components/Menu'
 
-const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
-
 export function ActionsWrapper({
   message,
   isFromSelf,
@@ -30,56 +29,59 @@ export function ActionsWrapper({
   const menuControl = useMenuControl()
 
   const scale = useSharedValue(1)
-  const animationDidComplete = useSharedValue(false)
 
   const animatedStyle = useAnimatedStyle(() => ({
     transform: [{scale: scale.value}],
   }))
 
-  // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
-  // function
   const open = React.useCallback(() => {
+    playHaptic()
     Keyboard.dismiss()
     menuControl.open()
-  }, [menuControl])
+  }, [menuControl, playHaptic])
 
   const shrink = React.useCallback(() => {
     'worklet'
     cancelAnimation(scale)
-    scale.value = withTiming(1, {duration: 200}, () => {
-      animationDidComplete.value = false
-    })
-  }, [animationDidComplete, scale])
+    scale.value = withTiming(1, {duration: 200})
+  }, [scale])
 
-  const grow = React.useCallback(() => {
-    'worklet'
-    scale.value = withTiming(1.05, {duration: 450}, finished => {
-      if (!finished) return
-      animationDidComplete.value = true
-      runOnJS(playHaptic)()
-      runOnJS(open)()
+  const doubleTapGesture = Gesture.Tap()
+    .numberOfTaps(2)
+    .hitSlop(HITSLOP_10)
+    .onEnd(open)
 
-      shrink()
+  const pressAndHoldGesture = Gesture.LongPress()
+    .onStart(() => {
+      scale.value = withTiming(1.05, {duration: 200}, finished => {
+        if (!finished) return
+        runOnJS(open)()
+        shrink()
+      })
     })
-  }, [scale, animationDidComplete, playHaptic, shrink, open])
+    .onTouchesUp(shrink)
+    .onTouchesMove(shrink)
+    .cancelsTouchesInView(false)
+    .runOnJS(true)
+
+  const composedGestures = Gesture.Exclusive(
+    doubleTapGesture,
+    pressAndHoldGesture,
+  )
 
   return (
-    <View
-      style={[
-        {
-          maxWidth: '80%',
-        },
-        isFromSelf ? a.self_end : a.self_start,
-      ]}>
-      <AnimatedPressable
-        style={animatedStyle}
-        unstable_pressDelay={200}
-        onPressIn={grow}
-        onTouchEnd={shrink}
-        hitSlop={HITSLOP_10}>
+    <GestureDetector gesture={composedGestures}>
+      <Animated.View
+        style={[
+          {
+            maxWidth: '80%',
+          },
+          isFromSelf ? a.self_end : a.self_start,
+          animatedStyle,
+        ]}>
         {children}
-      </AnimatedPressable>
-      <MessageMenu message={message} control={menuControl} />
-    </View>
+        <MessageMenu message={message} control={menuControl} />
+      </Animated.View>
+    </GestureDetector>
   )
 }
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 1e6fd3609..a6dff4032 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -1,5 +1,5 @@
 import React, {useCallback} from 'react'
-import {Keyboard, TouchableOpacity, View} from 'react-native'
+import {TouchableOpacity, View} from 'react-native'
 import {
   AppBskyActorDefs,
   ModerationCause,
@@ -46,7 +46,6 @@ export let MessagesListHeader = ({
     if (isWeb) {
       navigation.replace('Messages', {})
     } else {
-      Keyboard.dismiss()
       navigation.goBack()
     }
   }, [navigation])
diff --git a/src/components/dms/NewMessagesPill.tsx b/src/components/dms/NewMessagesPill.tsx
index 4a0ba22c9..924f7c455 100644
--- a/src/components/dms/NewMessagesPill.tsx
+++ b/src/components/dms/NewMessagesPill.tsx
@@ -1,47 +1,97 @@
 import React from 'react'
-import {View} from 'react-native'
-import Animated from 'react-native-reanimated'
+import {Pressable, View} from 'react-native'
+import Animated, {
+  runOnJS,
+  useAnimatedStyle,
+  useSharedValue,
+  withTiming,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {Trans} from '@lingui/macro'
 
 import {
   ScaleAndFadeIn,
   ScaleAndFadeOut,
 } from 'lib/custom-animations/ScaleAndFade'
+import {useHaptics} from 'lib/haptics'
+import {isAndroid, isIOS, isWeb} from 'platform/detection'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 
-export function NewMessagesPill() {
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+export function NewMessagesPill({
+  onPress: onPressInner,
+}: {
+  onPress: () => void
+}) {
   const t = useTheme()
+  const playHaptic = useHaptics()
+  const {bottom: bottomInset} = useSafeAreaInsets()
+  const bottomBarHeight = isIOS ? 42 : isAndroid ? 60 : 0
+  const bottomOffset = isWeb ? 0 : bottomInset + bottomBarHeight
+
+  const scale = useSharedValue(1)
+
+  const onPressIn = React.useCallback(() => {
+    if (isWeb) return
+    scale.value = withTiming(1.075, {duration: 100})
+  }, [scale])
+
+  const onPressOut = React.useCallback(() => {
+    if (isWeb) return
+    scale.value = withTiming(1, {duration: 100})
+  }, [scale])
+
+  const onPress = React.useCallback(() => {
+    runOnJS(playHaptic)()
+    onPressInner?.()
+  }, [onPressInner, playHaptic])
 
-  React.useEffect(() => {}, [])
+  const animatedStyle = useAnimatedStyle(() => ({
+    transform: [{scale: scale.value}],
+  }))
 
   return (
-    <Animated.View
+    <View
       style={[
-        a.py_sm,
-        a.rounded_full,
-        a.shadow_sm,
-        a.border,
-        t.atoms.bg_contrast_50,
-        t.atoms.border_contrast_medium,
+        a.absolute,
+        a.w_full,
+        a.z_10,
+        a.align_center,
         {
-          position: 'absolute',
-          bottom: 70,
-          width: '40%',
-          left: '30%',
-          alignItems: 'center',
-          shadowOpacity: 0.125,
-          shadowRadius: 12,
-          shadowOffset: {width: 0, height: 5},
+          bottom: bottomOffset + 70,
+          // Don't prevent scrolling in this area _except_ for in the pill itself
+          pointerEvents: 'box-none',
         },
-      ]}
-      entering={ScaleAndFadeIn}
-      exiting={ScaleAndFadeOut}>
-      <View style={{flex: 1}}>
+      ]}>
+      <AnimatedPressable
+        style={[
+          a.py_sm,
+          a.rounded_full,
+          a.shadow_sm,
+          a.border,
+          t.atoms.bg_contrast_50,
+          t.atoms.border_contrast_medium,
+          {
+            width: 160,
+            alignItems: 'center',
+            shadowOpacity: 0.125,
+            shadowRadius: 12,
+            shadowOffset: {width: 0, height: 5},
+            pointerEvents: 'box-only',
+          },
+          animatedStyle,
+        ]}
+        entering={ScaleAndFadeIn}
+        exiting={ScaleAndFadeOut}
+        onPress={onPress}
+        onPressIn={onPressIn}
+        onPressOut={onPressOut}>
         <Text style={[a.font_bold]}>
           <Trans>New messages</Trans>
         </Text>
-      </View>
-    </Animated.View>
+      </AnimatedPressable>
+    </View>
   )
 }
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index 9deecfd49..1e33efdf6 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -58,6 +58,9 @@ export function MessageInput({
     onSendMessage(message.trimEnd())
     playHaptic()
     setMessage('')
+
+    // Pressing the send button causes the text input to lose focus, so we need to
+    // re-focus it after sending
     setTimeout(() => {
       inputRef.current?.focus()
     }, 100)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index b0723c020..68e68b8cb 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -1,10 +1,12 @@
 import React, {useCallback, useRef} from 'react'
 import {FlatList, View} from 'react-native'
-import Animated, {
+import {
+  KeyboardStickyView,
+  useKeyboardHandler,
+} from 'react-native-keyboard-controller'
+import {
   runOnJS,
   scrollTo,
-  useAnimatedKeyboard,
-  useAnimatedReaction,
   useAnimatedRef,
   useAnimatedStyle,
   useSharedValue,
@@ -24,7 +26,6 @@ import {List} from 'view/com/util/List'
 import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
 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 {NewMessagesPill} from '#/components/dms/NewMessagesPill'
 import {Loader} from '#/components/Loader'
@@ -80,7 +81,10 @@ export function MessagesList({
 
   const flatListRef = useAnimatedRef<FlatList>()
 
-  const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
+  const [newMessagesPill, setNewMessagesPill] = React.useState({
+    show: false,
+    startContentOffset: 0,
+  })
 
   // 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
@@ -95,8 +99,14 @@ export function MessagesList({
   const prevContentHeight = useRef(0)
   const prevItemCount = useRef(0)
 
-  const isDragging = useSharedValue(false)
+  // -- Keep track of background state and positioning for new pill
   const layoutHeight = useSharedValue(0)
+  const didBackground = React.useRef(false)
+  React.useEffect(() => {
+    if (convoState.status === ConvoStatus.Backgrounded) {
+      didBackground.current = true
+    }
+  }, [convoState.status])
 
   // -- Scroll handling
 
@@ -123,24 +133,28 @@ export function MessagesList({
 
       // This number _must_ be the height of the MaybeLoader component
       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 the size of the content is changing by more than the height of the screen, then we don't
+        // want to scroll further than the start of all the new content. Since we are storing the previous offset,
+        // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
+        // that can be pressed to immediately scroll to the end.
         if (
+          didBackground.current &&
           hasScrolled &&
           height - prevContentHeight.current > layoutHeight.value - 50 &&
           convoState.items.length - prevItemCount.current > 1
         ) {
           flatListRef.current?.scrollToOffset({
-            offset: height - layoutHeight.value + 50,
-            animated: hasScrolled,
+            offset: prevContentHeight.current - 65,
+            animated: true,
+          })
+          setNewMessagesPill({
+            show: true,
+            startContentOffset: prevContentHeight.current - 65,
           })
-          setShowNewMessagesPill(true)
         } else {
           flatListRef.current?.scrollToOffset({
             offset: height,
-            animated: hasScrolled,
+            animated: hasScrolled && height > prevContentHeight.current,
           })
 
           // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
@@ -158,6 +172,7 @@ export function MessagesList({
 
       prevContentHeight.current = height
       prevItemCount.current = convoState.items.length
+      didBackground.current = false
     },
     [
       hasScrolled,
@@ -172,88 +187,66 @@ export function MessagesList({
     ],
   )
 
-  const onBeginDrag = React.useCallback(() => {
-    'worklet'
-    isDragging.value = true
-  }, [isDragging])
-
-  const onEndDrag = React.useCallback(() => {
-    'worklet'
-    isDragging.value = false
-  }, [isDragging])
-
   const onStartReached = useCallback(() => {
-    if (hasScrolled) {
+    if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
       convoState.fetchMessageHistory()
     }
-  }, [convoState, hasScrolled])
+  }, [convoState, hasScrolled, layoutHeight.value])
 
   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
+
+      if (
+        newMessagesPill.show &&
+        (e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
+          isAtBottom.value)
+      ) {
+        runOnJS(setNewMessagesPill)({
+          show: false,
+          startContentOffset: 0,
+        })
+      }
     },
-    [layoutHeight, showNewMessagesPill, isAtBottom, isAtTop],
+    [layoutHeight, newMessagesPill, isAtBottom, isAtTop],
   )
 
   // -- 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
-  // 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) {
-        return
-      }
 
-      // We are setting some arbitrarily high number here to ensure that we end up scrolling to the bottom. There is not
-      // any other way to synchronously scroll to the bottom of the list, since we cannot get the content size of the
-      // scrollview synchronously.
-      // On iOS we could have used `dispatchCommand('scrollToEnd', [])` since the underlying view has a `scrollToEnd`
-      // method. It doesn't exist on Android though. That's probably why `scrollTo` which is implemented in Reanimated
-      // doesn't support a `scrollToEnd`.
-      if (prev && now > 0 && now >= prev) {
-        scrollTo(flatListRef, 0, 1e7, false)
-      }
+  const keyboardHeight = useSharedValue(0)
+  const keyboardIsOpening = useSharedValue(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
+  useKeyboardHandler({
+    onStart: () => {
+      'worklet'
+      keyboardIsOpening.value = true
+    },
+    onMove: e => {
+      'worklet'
+      keyboardHeight.value = e.height
+      if (e.height > bottomOffset) {
+        scrollTo(flatListRef, 0, 1e7, false)
       }
     },
-  )
+    onEnd: () => {
+      'worklet'
+      keyboardIsOpening.value = false
+    },
+  })
 
-  // 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(() => ({
+  const animatedListStyle = useAnimatedStyle(() => ({
     marginBottom:
-      animatedKeyboard.height.value > bottomOffset
-        ? animatedKeyboard.height.value
-        : bottomOffset,
+      keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
   }))
 
   // -- Message sending
@@ -282,36 +275,25 @@ export function MessagesList({
     [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.
+  // -- List layout changes (opening emoji keyboard, 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)) {
+    if (keyboardIsOpening.value) return
+    if (isWeb || !keyboardIsOpening.value) {
       flatListRef.current?.scrollToEnd({animated: true})
     }
-  }, [
-    flatListRef,
-    finalKeyboardHeight.value,
-    animatedKeyboard.height.value,
-    isDragging.value,
-  ])
+  }, [flatListRef, keyboardIsOpening.value])
+
+  const scrollToEndOnPress = React.useCallback(() => {
+    flatListRef.current?.scrollToOffset({
+      offset: prevContentHeight.current,
+      animated: true,
+    })
+  }, [flatListRef])
 
   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}
-        onBeginDrag={onBeginDrag}
-        onEndDrag={onEndDrag}>
+      <ScrollProvider onScroll={onScroll}>
         <List
           ref={flatListRef}
           data={convoState.items}
@@ -319,13 +301,14 @@ export function MessagesList({
           keyExtractor={keyExtractor}
           containWeb={true}
           disableVirtualization={true}
+          style={animatedListStyle}
           // The extra two items account for the header and the footer components
           initialNumToRender={isNative ? 32 : 62}
           maxToRenderPerBatch={isWeb ? 32 : 62}
           keyboardDismissMode="on-drag"
           keyboardShouldPersistTaps="handled"
           maintainVisibleContentPosition={{
-            minIndexForVisible: 1,
+            minIndexForVisible: 0,
           }}
           removeClippedSubviews={false}
           sideBorders={false}
@@ -339,18 +322,20 @@ export function MessagesList({
           }
         />
       </ScrollProvider>
-      {!blocked ? (
-        <>
-          {convoState.status === ConvoStatus.Disabled ? (
-            <ChatDisabled />
-          ) : (
-            <MessageInput onSendMessage={onSendMessage} />
-          )}
-        </>
-      ) : (
-        footer
-      )}
-      {showNewMessagesPill && <NewMessagesPill />}
-    </Animated.View>
+      <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
+        {!blocked ? (
+          <>
+            {convoState.status === ConvoStatus.Disabled ? (
+              <ChatDisabled />
+            ) : (
+              <MessageInput onSendMessage={onSendMessage} />
+            )}
+          </>
+        ) : (
+          footer
+        )}
+      </KeyboardStickyView>
+      {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
+    </>
   )
 }
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 8e806ff7a..63175b551 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -83,6 +83,14 @@ function Inner() {
       !convoState.isFetchingHistory &&
       convoState.items.length === 0)
 
+  // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
+  // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
+  React.useEffect(() => {
+    if (convoState.status === ConvoStatus.Initializing) {
+      setHasScrolled(false)
+    }
+  }, [convoState.status])
+
   if (convoState.status === ConvoStatus.Error) {
     return (
       <CenteredView style={a.flex_1} sideBorders>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 61c339024..d85fca299 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -3,13 +3,15 @@ import {
   ActivityIndicator,
   BackHandler,
   Keyboard,
-  KeyboardAvoidingView,
-  Platform,
   ScrollView,
   StyleSheet,
   TouchableOpacity,
   View,
 } from 'react-native'
+import {
+  KeyboardAvoidingView,
+  KeyboardStickyView,
+} from 'react-native-keyboard-controller'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {LinearGradient} from 'expo-linear-gradient'
 import {RichText} from '@atproto/api'
@@ -373,172 +375,178 @@ export const ComposePost = observer(function ComposePost({
   )
 
   return (
-    <KeyboardAvoidingView
-      testID="composePostView"
-      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
-      style={styles.outer}>
-      <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
-        <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
-          <TouchableOpacity
-            testID="composerDiscardButton"
-            onPress={onPressCancel}
-            onAccessibilityEscape={onPressCancel}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Cancel`)}
-            accessibilityHint={_(
-              msg`Closes post composer and discards post draft`,
-            )}>
-            <Text style={[pal.link, s.f18]}>
-              <Trans>Cancel</Trans>
-            </Text>
-          </TouchableOpacity>
-          <View style={s.flex1} />
-          {isProcessing ? (
-            <>
-              <Text style={pal.textLight}>{processingState}</Text>
-              <View style={styles.postBtn}>
-                <ActivityIndicator />
-              </View>
-            </>
-          ) : (
-            <>
-              <LabelsBtn
-                labels={labels}
-                onChange={setLabels}
-                hasMedia={hasMedia}
-              />
-              {replyTo ? null : (
-                <ThreadgateBtn
-                  threadgate={threadgate}
-                  onChange={setThreadgate}
+    <>
+      <KeyboardAvoidingView
+        testID="composePostView"
+        behavior="padding"
+        style={s.flex1}
+        keyboardVerticalOffset={60}>
+        <View style={[s.flex1, viewStyles]} aria-modal accessibilityViewIsModal>
+          <View style={[styles.topbar, isDesktop && styles.topbarDesktop]}>
+            <TouchableOpacity
+              testID="composerDiscardButton"
+              onPress={onPressCancel}
+              onAccessibilityEscape={onPressCancel}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Cancel`)}
+              accessibilityHint={_(
+                msg`Closes post composer and discards post draft`,
+              )}>
+              <Text style={[pal.link, s.f18]}>
+                <Trans>Cancel</Trans>
+              </Text>
+            </TouchableOpacity>
+            <View style={s.flex1} />
+            {isProcessing ? (
+              <>
+                <Text style={pal.textLight}>{processingState}</Text>
+                <View style={styles.postBtn}>
+                  <ActivityIndicator />
+                </View>
+              </>
+            ) : (
+              <>
+                <LabelsBtn
+                  labels={labels}
+                  onChange={setLabels}
+                  hasMedia={hasMedia}
                 />
-              )}
-              {canPost ? (
-                <TouchableOpacity
-                  testID="composerPublishBtn"
-                  onPress={onPressPublish}
-                  accessibilityRole="button"
-                  accessibilityLabel={
-                    replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
-                  }
-                  accessibilityHint="">
-                  <LinearGradient
-                    colors={[
-                      gradients.blueLight.start,
-                      gradients.blueLight.end,
-                    ]}
-                    start={{x: 0, y: 0}}
-                    end={{x: 1, y: 1}}
-                    style={styles.postBtn}>
-                    <Text style={[s.white, s.f16, s.bold]}>
-                      {replyTo ? (
-                        <Trans context="action">Reply</Trans>
-                      ) : (
-                        <Trans context="action">Post</Trans>
-                      )}
+                {replyTo ? null : (
+                  <ThreadgateBtn
+                    threadgate={threadgate}
+                    onChange={setThreadgate}
+                  />
+                )}
+                {canPost ? (
+                  <TouchableOpacity
+                    testID="composerPublishBtn"
+                    onPress={onPressPublish}
+                    accessibilityRole="button"
+                    accessibilityLabel={
+                      replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
+                    }
+                    accessibilityHint="">
+                    <LinearGradient
+                      colors={[
+                        gradients.blueLight.start,
+                        gradients.blueLight.end,
+                      ]}
+                      start={{x: 0, y: 0}}
+                      end={{x: 1, y: 1}}
+                      style={styles.postBtn}>
+                      <Text style={[s.white, s.f16, s.bold]}>
+                        {replyTo ? (
+                          <Trans context="action">Reply</Trans>
+                        ) : (
+                          <Trans context="action">Post</Trans>
+                        )}
+                      </Text>
+                    </LinearGradient>
+                  </TouchableOpacity>
+                ) : (
+                  <View style={[styles.postBtn, pal.btn]}>
+                    <Text style={[pal.textLight, s.f16, s.bold]}>
+                      <Trans context="action">Post</Trans>
                     </Text>
-                  </LinearGradient>
-                </TouchableOpacity>
-              ) : (
-                <View style={[styles.postBtn, pal.btn]}>
-                  <Text style={[pal.textLight, s.f16, s.bold]}>
-                    <Trans context="action">Post</Trans>
-                  </Text>
-                </View>
-              )}
-            </>
-          )}
-        </View>
-        {isAltTextRequiredAndMissing && (
-          <View style={[styles.reminderLine, pal.viewLight]}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon
-                icon="exclamation"
-                style={{color: colors.red4}}
-                size={10}
-              />
-            </View>
-            <Text style={[pal.text, s.flex1]}>
-              <Trans>One or more images is missing alt text.</Trans>
-            </Text>
+                  </View>
+                )}
+              </>
+            )}
           </View>
-        )}
-        {error !== '' && (
-          <View style={styles.errorLine}>
-            <View style={styles.errorIcon}>
-              <FontAwesomeIcon
-                icon="exclamation"
-                style={{color: colors.red4}}
-                size={10}
-              />
+          {isAltTextRequiredAndMissing && (
+            <View style={[styles.reminderLine, pal.viewLight]}>
+              <View style={styles.errorIcon}>
+                <FontAwesomeIcon
+                  icon="exclamation"
+                  style={{color: colors.red4}}
+                  size={10}
+                />
+              </View>
+              <Text style={[pal.text, s.flex1]}>
+                <Trans>One or more images is missing alt text.</Trans>
+              </Text>
             </View>
-            <Text style={[s.red4, s.flex1]}>{error}</Text>
-          </View>
-        )}
-        <ScrollView
-          style={styles.scrollView}
-          keyboardShouldPersistTaps="always">
-          {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
-
-          <View
-            style={[
-              pal.border,
-              styles.textInputLayout,
-              isNative && styles.textInputLayoutMobile,
-            ]}>
-            <UserAvatar
-              avatar={currentProfile?.avatar}
-              size={50}
-              type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
-            />
-            <TextInput
-              ref={textInput}
-              richtext={richtext}
-              placeholder={selectTextInputPlaceholder}
-              autoFocus={true}
-              setRichText={setRichText}
-              onPhotoPasted={onPhotoPasted}
-              onPressPublish={onPressPublish}
-              onNewLink={onNewLink}
-              onError={setError}
-              accessible={true}
-              accessibilityLabel={_(msg`Write post`)}
-              accessibilityHint={_(
-                msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
-              )}
-            />
-          </View>
+          )}
+          {error !== '' && (
+            <View style={styles.errorLine}>
+              <View style={styles.errorIcon}>
+                <FontAwesomeIcon
+                  icon="exclamation"
+                  style={{color: colors.red4}}
+                  size={10}
+                />
+              </View>
+              <Text style={[s.red4, s.flex1]}>{error}</Text>
+            </View>
+          )}
+          <ScrollView
+            style={styles.scrollView}
+            keyboardShouldPersistTaps="always">
+            {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
 
-          <Gallery gallery={gallery} />
-          {gallery.isEmpty && extLink && (
-            <View style={a.relative}>
-              <ExternalEmbed
-                link={extLink}
-                gif={extGif}
-                onRemove={() => {
-                  setExtLink(undefined)
-                  setExtGif(undefined)
-                }}
+            <View
+              style={[
+                pal.border,
+                styles.textInputLayout,
+                isNative && styles.textInputLayoutMobile,
+              ]}>
+              <UserAvatar
+                avatar={currentProfile?.avatar}
+                size={50}
+                type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
               />
-              <GifAltText
-                link={extLink}
-                gif={extGif}
-                onSubmit={handleChangeGifAltText}
+              <TextInput
+                ref={textInput}
+                richtext={richtext}
+                placeholder={selectTextInputPlaceholder}
+                autoFocus={true}
+                setRichText={setRichText}
+                onPhotoPasted={onPhotoPasted}
+                onPressPublish={onPressPublish}
+                onNewLink={onNewLink}
+                onError={setError}
+                accessible={true}
+                accessibilityLabel={_(msg`Write post`)}
+                accessibilityHint={_(
+                  msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
+                )}
               />
             </View>
-          )}
-          {quote ? (
-            <View style={[s.mt5, isWeb && s.mb10]}>
-              <View style={{pointerEvents: 'none'}}>
-                <QuoteEmbed quote={quote} />
+
+            <Gallery gallery={gallery} />
+            {gallery.isEmpty && extLink && (
+              <View style={a.relative}>
+                <ExternalEmbed
+                  link={extLink}
+                  gif={extGif}
+                  onRemove={() => {
+                    setExtLink(undefined)
+                    setExtGif(undefined)
+                  }}
+                />
+                <GifAltText
+                  link={extLink}
+                  gif={extGif}
+                  onSubmit={handleChangeGifAltText}
+                />
               </View>
-              {quote.uri !== initQuote?.uri && (
-                <QuoteX onRemove={() => setQuote(undefined)} />
-              )}
-            </View>
-          ) : undefined}
-        </ScrollView>
-        <SuggestedLanguage text={richtext.text} />
+            )}
+            {quote ? (
+              <View style={[s.mt5, isWeb && s.mb10]}>
+                <View style={{pointerEvents: 'none'}}>
+                  <QuoteEmbed quote={quote} />
+                </View>
+                {quote.uri !== initQuote?.uri && (
+                  <QuoteX onRemove={() => setQuote(undefined)} />
+                )}
+              </View>
+            ) : undefined}
+          </ScrollView>
+          <SuggestedLanguage text={richtext.text} />
+        </View>
+      </KeyboardAvoidingView>
+      <KeyboardStickyView
+        offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}>
         <View style={[pal.border, styles.bottomBar]}>
           <View style={[a.flex_row, a.align_center, a.gap_xs]}>
             <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
@@ -565,8 +573,7 @@ export const ComposePost = observer(function ComposePost({
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
         </View>
-      </View>
-
+      </KeyboardStickyView>
       <Prompt.Basic
         control={discardPromptControl}
         title={_(msg`Discard draft?`)}
@@ -575,7 +582,7 @@ export const ComposePost = observer(function ComposePost({
         confirmButtonCta={_(msg`Discard`)}
         confirmButtonColor="negative"
       />
-    </KeyboardAvoidingView>
+    </>
   )
 })