about summary refs log tree commit diff
path: root/src/screens/Messages/Conversation
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/Conversation')
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx94
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx85
-rw-r--r--src/screens/Messages/Conversation/MessageItem.tsx165
-rw-r--r--src/screens/Messages/Conversation/MessageListError.tsx60
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx248
-rw-r--r--src/screens/Messages/Conversation/index.tsx65
6 files changed, 356 insertions, 361 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index 3848bcab3..3de15e661 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -12,20 +12,21 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {HITSLOP_10} from '#/lib/constants'
+import {useHaptics} from 'lib/haptics'
 import {atoms as a, useTheme} from '#/alf'
 import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
 
 export function MessageInput({
   onSendMessage,
-  onFocus,
-  onBlur,
+  scrollToEnd,
 }: {
   onSendMessage: (message: string) => void
-  onFocus: () => void
-  onBlur: () => void
+  scrollToEnd: () => void
 }) {
   const {_} = useLingui()
   const t = useTheme()
+  const playHaptic = useHaptics()
   const [message, setMessage] = React.useState('')
   const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
   const [isInputScrollable, setIsInputScrollable] = React.useState(false)
@@ -39,11 +40,12 @@ export function MessageInput({
       return
     }
     onSendMessage(message.trimEnd())
+    playHaptic()
     setMessage('')
     setTimeout(() => {
       inputRef.current?.focus()
     }, 100)
-  }, [message, onSendMessage])
+  }, [message, onSendMessage, playHaptic])
 
   const onInputLayout = React.useCallback(
     (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
@@ -55,49 +57,55 @@ export function MessageInput({
 
       setMaxHeight(max)
       setIsInputScrollable(availableSpace < 30)
+
+      scrollToEnd()
     },
-    [topInset],
+    [scrollToEnd, topInset],
   )
 
   return (
-    <View
-      style={[
-        a.flex_row,
-        a.py_sm,
-        a.px_sm,
-        a.pl_md,
-        a.mt_sm,
-        t.atoms.bg_contrast_25,
-        {borderRadius: 23},
-      ]}>
-      <TextInput
-        accessibilityLabel={_(msg`Message input field`)}
-        accessibilityHint={_(msg`Type your message here`)}
-        placeholder={_(msg`Write a message`)}
-        placeholderTextColor={t.palette.contrast_500}
-        value={message}
-        multiline={true}
-        onChangeText={setMessage}
-        style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
-        keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
-        scrollEnabled={isInputScrollable}
-        blurOnSubmit={false}
-        onFocus={onFocus}
-        onBlur={onBlur}
-        onContentSizeChange={onInputLayout}
-        ref={inputRef}
-      />
-      <Pressable
-        accessibilityRole="button"
+    <View style={a.p_sm}>
+      <View
         style={[
-          a.rounded_full,
-          a.align_center,
-          a.justify_center,
-          {height: 30, width: 30, backgroundColor: t.palette.primary_500},
-        ]}
-        onPress={onSubmit}>
-        <PaperPlane fill={t.palette.white} />
-      </Pressable>
+          a.w_full,
+          a.flex_row,
+          a.py_sm,
+          a.px_sm,
+          a.pl_md,
+          t.atoms.bg_contrast_25,
+          {borderRadius: 23},
+        ]}>
+        <TextInput
+          accessibilityLabel={_(msg`Message input field`)}
+          accessibilityHint={_(msg`Type your message here`)}
+          placeholder={_(msg`Write a message`)}
+          placeholderTextColor={t.palette.contrast_500}
+          value={message}
+          multiline={true}
+          onChangeText={setMessage}
+          style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
+          keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
+          scrollEnabled={isInputScrollable}
+          blurOnSubmit={false}
+          onFocus={scrollToEnd}
+          onContentSizeChange={onInputLayout}
+          ref={inputRef}
+        />
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Send message`)}
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+          ]}
+          onPress={onSubmit}>
+          <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
+        </Pressable>
+      </View>
     </View>
   )
 }
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
index 5ecaad3ae..a2f255bdc 100644
--- a/src/screens/Messages/Conversation/MessageInput.web.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -11,8 +11,7 @@ export function MessageInput({
   onSendMessage,
 }: {
   onSendMessage: (message: string) => void
-  onFocus: () => void
-  onBlur: () => void
+  scrollToEnd: () => void
 }) {
   const {_} = useLingui()
   const t = useTheme()
@@ -45,47 +44,51 @@ export function MessageInput({
   )
 
   return (
-    <View
-      style={[
-        a.flex_row,
-        a.py_sm,
-        a.px_sm,
-        a.pl_md,
-        a.mt_sm,
-        t.atoms.bg_contrast_25,
-        {borderRadius: 23},
-      ]}>
-      <TextareaAutosize
-        style={StyleSheet.flatten([
-          a.flex_1,
-          a.px_sm,
-          a.border_0,
-          t.atoms.text,
-          {
-            backgroundColor: 'transparent',
-            resize: 'none',
-            paddingTop: 6,
-          },
-        ])}
-        maxRows={12}
-        placeholder={_(msg`Write a message`)}
-        defaultValue=""
-        value={message}
-        dirName="ltr"
-        autoFocus={true}
-        onChange={onChange}
-        onKeyDown={onKeyDown}
-      />
-      <Pressable
-        accessibilityRole="button"
+    <View style={a.p_sm}>
+      <View
         style={[
-          a.rounded_full,
-          a.align_center,
-          a.justify_center,
-          {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+          a.flex_row,
+          a.py_sm,
+          a.px_sm,
+          a.pl_md,
+          t.atoms.bg_contrast_25,
+          {borderRadius: 23},
         ]}>
-        <PaperPlane fill={t.palette.white} />
-      </Pressable>
+        <TextareaAutosize
+          style={StyleSheet.flatten([
+            a.flex_1,
+            a.px_sm,
+            a.border_0,
+            t.atoms.text,
+            {
+              backgroundColor: 'transparent',
+              resize: 'none',
+              paddingTop: 4,
+            },
+          ])}
+          maxRows={12}
+          placeholder={_(msg`Write a message`)}
+          defaultValue=""
+          value={message}
+          dirName="ltr"
+          autoFocus={true}
+          onChange={onChange}
+          onKeyDown={onKeyDown}
+        />
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Send message`)}
+          accessibilityHint=""
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+          ]}
+          onPress={onSubmit}>
+          <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
+        </Pressable>
+      </View>
     </View>
   )
 }
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx
deleted file mode 100644
index ba10978e8..000000000
--- a/src/screens/Messages/Conversation/MessageItem.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import React, {useCallback, useMemo} from 'react'
-import {StyleProp, TextStyle, View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto-labs/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {useSession} from '#/state/session'
-import {TimeElapsed} from '#/view/com/util/TimeElapsed'
-import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
-
-export function MessageItem({
-  item,
-  next,
-}: {
-  item: ChatBskyConvoDefs.MessageView
-  next:
-    | ChatBskyConvoDefs.MessageView
-    | ChatBskyConvoDefs.DeletedMessageView
-    | null
-}) {
-  const t = useTheme()
-  const {currentAccount} = useSession()
-
-  const isFromSelf = item.sender?.did === currentAccount?.did
-
-  const isNextFromSelf =
-    ChatBskyConvoDefs.isMessageView(next) &&
-    next.sender?.did === currentAccount?.did
-
-  const isLastInGroup = useMemo(() => {
-    // if the next message is from a different sender, then it's the last in the group
-    if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
-      return true
-    }
-
-    // or, if there's a 10 minute gap between this message and the next
-    if (ChatBskyConvoDefs.isMessageView(next)) {
-      const thisDate = new Date(item.sentAt)
-      const nextDate = new Date(next.sentAt)
-
-      const diff = nextDate.getTime() - thisDate.getTime()
-
-      // 10 minutes
-      return diff > 10 * 60 * 1000
-    }
-
-    return true
-  }, [item, next, isFromSelf, isNextFromSelf])
-
-  return (
-    <View>
-      <View
-        style={[
-          a.py_sm,
-          a.px_lg,
-          a.my_2xs,
-          a.rounded_md,
-          isFromSelf ? a.self_end : a.self_start,
-          {
-            maxWidth: '65%',
-            backgroundColor: isFromSelf
-              ? t.palette.primary_500
-              : t.palette.contrast_50,
-            borderRadius: 17,
-          },
-          isFromSelf
-            ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
-            : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
-        ]}>
-        <Text
-          style={[
-            a.text_md,
-            a.leading_snug,
-            isFromSelf && {color: t.palette.white},
-          ]}>
-          {item.text}
-        </Text>
-      </View>
-      <Metadata
-        message={item}
-        isLastInGroup={isLastInGroup}
-        style={isFromSelf ? a.text_right : a.text_left}
-      />
-    </View>
-  )
-}
-
-function Metadata({
-  message,
-  isLastInGroup,
-  style,
-}: {
-  message: ChatBskyConvoDefs.MessageView
-  isLastInGroup: boolean
-  style: StyleProp<TextStyle>
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-
-  const relativeTimestamp = useCallback(
-    (timestamp: string) => {
-      const date = new Date(timestamp)
-      const now = new Date()
-
-      const time = new Intl.DateTimeFormat(undefined, {
-        hour: 'numeric',
-        minute: 'numeric',
-        hour12: true,
-      }).format(date)
-
-      const diff = now.getTime() - date.getTime()
-
-      // if under 1 minute
-      if (diff < 1000 * 60) {
-        return _(msg`Now`)
-      }
-
-      // if in the last day
-      if (now.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)) {
-        return time
-      }
-
-      // if yesterday
-      const yesterday = new Date(now)
-      yesterday.setDate(yesterday.getDate() - 1)
-      if (
-        yesterday.toISOString().slice(0, 10) === date.toISOString().slice(0, 10)
-      ) {
-        return _(msg`Yesterday, ${time}`)
-      }
-
-      return new Intl.DateTimeFormat(undefined, {
-        hour: 'numeric',
-        minute: 'numeric',
-        hour12: true,
-        day: 'numeric',
-        month: 'numeric',
-        year: 'numeric',
-      }).format(date)
-    },
-    [_],
-  )
-
-  if (!isLastInGroup) {
-    return null
-  }
-
-  return (
-    <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
-      {({timeElapsed}) => (
-        <Text
-          style={[
-            t.atoms.text_contrast_medium,
-            a.text_xs,
-            a.mt_xs,
-            a.mb_lg,
-            style,
-          ]}>
-          {timeElapsed}
-        </Text>
-      )}
-    </TimeElapsed>
-  )
-}
diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx
new file mode 100644
index 000000000..523788d4d
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageListError.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
+import {atoms as a, useTheme} from '#/alf'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function MessageListError({
+  item,
+}: {
+  item: ConvoItem & {type: 'error-recoverable'}
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const message = React.useMemo(() => {
+    return {
+      [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
+      [ConvoItemError.ResumeFailed]: _(
+        msg`There was an issue connecting to the chat.`,
+      ),
+      [ConvoItemError.PollFailed]: _(
+        msg`This chat was disconnected due to a network error.`,
+      ),
+    }[item.code]
+  }, [_, item.code])
+
+  return (
+    <View style={[a.py_md, a.align_center]}>
+      <View
+        style={[
+          a.align_center,
+          a.pt_md,
+          a.pb_lg,
+          a.px_3xl,
+          a.rounded_md,
+          t.atoms.bg_contrast_25,
+          {maxWidth: 300},
+        ]}>
+        <CircleInfo size="lg" fill={t.palette.negative_400} />
+        <Text style={[a.pt_sm, a.leading_snug]}>
+          {message}{' '}
+          <InlineLinkText
+            to="#"
+            label={_(msg`Press to retry`)}
+            onPress={e => {
+              e.preventDefault()
+              item.retry()
+              return false
+            }}>
+            {_(msg`Retry.`)}
+          </InlineLinkText>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 5fedf062a..1dc26d6c3 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -1,20 +1,26 @@
 import React, {useCallback, useRef} from 'react'
+import {FlatList, View} from 'react-native'
 import {
-  FlatList,
-  NativeScrollEvent,
-  NativeSyntheticEvent,
-  View,
-} from 'react-native'
-import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
+  KeyboardAvoidingView,
+  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 {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {isIOS} from '#/platform/detection'
 import {useChat} from '#/state/messages'
 import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
+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 {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
+import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
+import {atoms as a, useBreakpoints} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
+import {MessageItem} from '#/components/dms/MessageItem'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 
@@ -53,11 +59,19 @@ function RetryButton({onPress}: {onPress: () => unknown}) {
 
 function renderItem({item}: {item: ConvoItem}) {
   if (item.type === 'message' || item.type === 'pending-message') {
-    return <MessageItem item={item.message} next={item.nextMessage} />
+    return (
+      <MessageItem
+        item={item.message}
+        next={item.nextMessage}
+        pending={item.type === 'pending-message'}
+      />
+    )
   } else if (item.type === 'deleted-message') {
     return <Text>Deleted message</Text>
   } else if (item.type === 'pending-retry') {
     return <RetryButton onPress={item.retry} />
+  } else if (item.type === 'error-recoverable') {
+    return <MessageListError item={item} />
   }
 
   return null
@@ -67,100 +81,178 @@ function keyExtractor(item: ConvoItem) {
   return item.key
 }
 
-function onScrollToEndFailed() {
+function onScrollToIndexFailed() {
   // Placeholder function. You have to give FlatList something or else it will error.
 }
 
 export function MessagesList() {
   const chat = useChat()
   const flatListRef = useRef<FlatList>(null)
-  // We use this to know if we should scroll after a new clop is added to the list
-  const isAtBottom = useRef(false)
-  const currentOffset = React.useRef(0)
 
-  const onContentSizeChange = useCallback(() => {
-    if (currentOffset.current <= 100) {
-      flatListRef.current?.scrollToOffset({offset: 0, animated: true})
-    }
-  }, [])
+  // 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)
 
-  const onEndReached = useCallback(() => {
-    chat.service.fetchMessageHistory()
-  }, [chat])
+  // This will be used on web to assist in determing if we need to maintain the content offset
+  const isAtTop = useSharedValue(true)
 
-  const onInputFocus = useCallback(() => {
-    if (!isAtBottom.current) {
-      flatListRef.current?.scrollToOffset({offset: 0, animated: 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, setHasInitiallyScrolled] = React.useState(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) {
+        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,
+        offset: height,
+      })
+      isMomentumScrolling.value = true
+    },
+    [
+      contentHeight,
+      hasInitiallyScrolled,
+      isAtBottom.value,
+      isAtTop.value,
+      isMomentumScrolling,
+    ],
+  )
 
-  const onInputBlur = useCallback(() => {}, [])
+  // 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 (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
+      chat.fetchMessageHistory()
+    }
+  }, [chat, hasInitiallyScrolled])
 
   const onSendMessage = useCallback(
     (text: string) => {
-      chat.service.sendMessage({
-        text,
-      })
+      if (chat.status === ConvoStatus.Ready) {
+        chat.sendMessage({
+          text,
+        })
+      }
     },
-    [chat.service],
+    [chat],
   )
 
   const onScroll = React.useCallback(
-    (e: NativeSyntheticEvent<NativeScrollEvent>) => {
-      currentOffset.current = e.nativeEvent.contentOffset.y
+    (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) {
+        runOnJS(setHasInitiallyScrolled)(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])
+
+  const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
+  const {gtMobile} = useBreakpoints()
+  const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
+
+  // 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 (
     <KeyboardAvoidingView
-      style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
+      style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
+      keyboardVerticalOffset={isIOS ? topInset : 0}
       behavior="padding"
-      keyboardVerticalOffset={70}
-      contentContainerStyle={{flex: 1}}>
-      <FlatList
-        data={
-          chat.state.status === ConvoStatus.Ready ? chat.state.items : undefined
-        }
-        keyExtractor={keyExtractor}
-        renderItem={renderItem}
-        contentContainerStyle={{paddingHorizontal: 10}}
-        // In the future, we might want to adjust this value. Not very concerning right now as long as we are only
-        // dealing with text. But whenever we have images or other media and things are taller, we will want to lower
-        // this...probably.
-        initialNumToRender={20}
-        // Same with the max to render per batch. Let's be safe for now though.
-        maxToRenderPerBatch={25}
-        inverted={true}
-        onEndReached={onEndReached}
-        onScrollToIndexFailed={onScrollToEndFailed}
-        onContentSizeChange={onContentSizeChange}
-        onScroll={onScroll}
-        // We don't really need to call this much since there are not any animations that rely on this
-        scrollEventThrottle={100}
-        maintainVisibleContentPosition={{
-          minIndexForVisible: 1,
-        }}
-        ListFooterComponent={
-          <MaybeLoader
-            isLoading={
-              chat.state.status === ConvoStatus.Ready &&
-              chat.state.isFetchingHistory
-            }
-          />
-        }
-        removeClippedSubviews={true}
-        ref={flatListRef}
-        keyboardDismissMode="none"
-      />
-
-      <View style={{paddingHorizontal: 10}}>
-        <MessageInput
-          onSendMessage={onSendMessage}
-          onFocus={onInputFocus}
-          onBlur={onInputBlur}
+      contentContainerStyle={a.flex_1}>
+      {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
+      <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
+        <List
+          ref={flatListRef}
+          data={chat.items}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          disableVirtualization={true}
+          initialNumToRender={isWeb ? 50 : 25}
+          maxToRenderPerBatch={isWeb ? 50 : 25}
+          keyboardDismissMode="on-drag"
+          keyboardShouldPersistTaps="handled"
+          maintainVisibleContentPosition={{
+            minIndexForVisible: 1,
+          }}
+          containWeb={true}
+          contentContainerStyle={{paddingHorizontal: 10}}
+          removeClippedSubviews={false}
+          onContentSizeChange={onContentSizeChange}
+          onStartReached={onStartReached}
+          onScrollToIndexFailed={onScrollToIndexFailed}
+          scrollEventThrottle={100}
+          ListHeaderComponent={
+            <MaybeLoader isLoading={chat.isFetchingHistory} />
+          }
         />
-      </View>
+      </ScrollProvider>
+      <MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
     </KeyboardAvoidingView>
   )
 }
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index f5663fdcb..11044c213 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -1,9 +1,9 @@
 import React, {useCallback} from 'react'
 import {TouchableOpacity, View} from 'react-native'
+import {KeyboardProvider} from 'react-native-keyboard-controller'
 import {AppBskyActorDefs} from '@atproto/api'
-import {ChatBskyConvoDefs} from '@atproto-labs/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -14,7 +14,6 @@ import {BACK_HITSLOP} from 'lib/constants'
 import {isWeb} from 'platform/detection'
 import {ChatProvider, useChat} from 'state/messages'
 import {ConvoStatus} from 'state/messages/convo'
-import {useSession} from 'state/session'
 import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
 import {CenteredView} from 'view/com/util/Views'
 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
@@ -43,29 +42,30 @@ export function MessagesConversationScreen({route}: Props) {
 
 function Inner() {
   const chat = useChat()
-  const {currentAccount} = useSession()
-  const myDid = currentAccount?.did
 
-  const otherProfile = React.useMemo(() => {
-    if (chat.state.status !== ConvoStatus.Ready) return
-    return chat.state.convo.members.find(m => m.did !== myDid)
-  }, [chat.state, myDid])
+  if (
+    chat.status === ConvoStatus.Uninitialized ||
+    chat.status === ConvoStatus.Initializing
+  ) {
+    return <ListMaybePlaceholder isLoading />
+  }
 
-  // TODO whenever we have error messages, we should use them in here -hailey
-  if (chat.state.status !== ConvoStatus.Ready || !otherProfile) {
-    return (
-      <ListMaybePlaceholder
-        isLoading={true}
-        isError={chat.state.status === ConvoStatus.Error}
-      />
-    )
+  if (chat.status === ConvoStatus.Error) {
+    // TODO error
+    return null
   }
 
+  /*
+   * Any other chat states (atm) are "ready" states
+   */
+
   return (
-    <CenteredView style={{flex: 1}} sideBorders>
-      <Header profile={otherProfile} />
-      <MessagesList />
-    </CenteredView>
+    <KeyboardProvider>
+      <CenteredView style={{flex: 1}} sideBorders>
+        <Header profile={chat.recipients[0]} />
+        <MessagesList />
+      </CenteredView>
+    </KeyboardProvider>
   )
 }
 
@@ -78,22 +78,19 @@ let Header = ({
   const {_} = useLingui()
   const {gtTablet} = useBreakpoints()
   const navigation = useNavigation<NavigationProp>()
-  const {service} = useChat()
+  const chat = useChat()
 
   const onPressBack = useCallback(() => {
     if (isWeb) {
-      navigation.replace('MessagesList')
+      navigation.replace('Messages')
     } else {
       navigation.pop()
     }
   }, [navigation])
 
-  const onUpdateConvo = useCallback(
-    (convo: ChatBskyConvoDefs.ConvoView) => {
-      service.convo = convo
-    },
-    [service],
-  )
+  const onUpdateConvo = useCallback(() => {
+    // TODO eric update muted state
+  }, [])
 
   return (
     <View
@@ -129,15 +126,15 @@ let Header = ({
       ) : (
         <View style={{width: 30}} />
       )}
-      <View style={[a.align_center, a.gap_sm]}>
+      <View style={[a.align_center, a.gap_sm, a.flex_1]}>
         <PreviewableUserAvatar size={32} profile={profile} />
-        <Text style={[a.text_lg, a.font_bold]}>
-          <Trans>{profile.displayName}</Trans>
+        <Text style={[a.text_lg, a.font_bold, a.text_center]}>
+          {profile.displayName}
         </Text>
       </View>
-      {service.convo ? (
+      {chat.status === ConvoStatus.Ready ? (
         <ConvoMenu
-          convo={service.convo}
+          convo={chat.convo}
           profile={profile}
           onUpdateConvo={onUpdateConvo}
           currentScreen="conversation"