about summary refs log tree commit diff
path: root/src/screens/Messages/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/components')
-rw-r--r--src/screens/Messages/components/ChatDisabled.tsx150
-rw-r--r--src/screens/Messages/components/ChatListItem.tsx378
-rw-r--r--src/screens/Messages/components/MessageInput.tsx180
-rw-r--r--src/screens/Messages/components/MessageInput.web.tsx238
-rw-r--r--src/screens/Messages/components/MessageInputEmbed.tsx219
-rw-r--r--src/screens/Messages/components/MessageListError.tsx61
-rw-r--r--src/screens/Messages/components/MessagesList.tsx454
7 files changed, 1680 insertions, 0 deletions
diff --git a/src/screens/Messages/components/ChatDisabled.tsx b/src/screens/Messages/components/ChatDisabled.tsx
new file mode 100644
index 000000000..c768d2504
--- /dev/null
+++ b/src/screens/Messages/components/ChatDisabled.tsx
@@ -0,0 +1,150 @@
+import React, {useCallback, useState} from 'react'
+import {View} from 'react-native'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function ChatDisabled() {
+  const t = useTheme()
+  return (
+    <View style={[a.p_md]}>
+      <View
+        style={[a.align_start, a.p_xl, a.rounded_md, t.atoms.bg_contrast_25]}>
+        <Text
+          style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
+          <Trans>Your chats have been disabled</Trans>
+        </Text>
+        <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Our moderators have reviewed reports and decided to disable your
+            access to chats on Bluesky.
+          </Trans>
+        </Text>
+        <AppealDialog />
+      </View>
+    </View>
+  )
+}
+
+function AppealDialog() {
+  const control = Dialog.useDialogControl()
+  const {_} = useLingui()
+
+  return (
+    <>
+      <Button
+        testID="appealDisabledChatBtn"
+        variant="ghost"
+        color="secondary"
+        size="small"
+        onPress={control.open}
+        label={_(msg`Appeal this decision`)}
+        style={a.mt_sm}>
+        <ButtonText>{_(msg`Appeal this decision`)}</ButtonText>
+      </Button>
+
+      <Dialog.Outer control={control}>
+        <Dialog.Handle />
+        <DialogInner />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function DialogInner() {
+  const {_} = useLingui()
+  const control = Dialog.useDialogContext()
+  const [details, setDetails] = useState('')
+  const {gtMobile} = useBreakpoints()
+  const agent = useAgent()
+  const {currentAccount} = useSession()
+
+  const {mutate, isPending} = useMutation({
+    mutationFn: async () => {
+      if (!currentAccount)
+        throw new Error('No current account, should be unreachable')
+      await agent.createModerationReport({
+        reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+        subject: {
+          $type: 'com.atproto.admin.defs#repoRef',
+          did: currentAccount.did,
+        },
+        reason: details,
+      })
+    },
+    onError: err => {
+      logger.error('Failed to submit chat appeal', {message: err})
+      Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark')
+    },
+    onSuccess: () => {
+      control.close()
+      Toast.show(_(msg`Appeal submitted`))
+    },
+  })
+
+  const onSubmit = useCallback(() => mutate(), [mutate])
+  const onBack = useCallback(() => control.close(), [control])
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Appeal this decision`)}>
+      <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+        <Trans>Appeal this decision</Trans>
+      </Text>
+      <Text style={[a.text_md, a.leading_snug]}>
+        <Trans>This appeal will be sent to Bluesky's moderation service.</Trans>
+      </Text>
+      <View style={[a.my_md]}>
+        <Dialog.Input
+          label={_(msg`Text input field`)}
+          placeholder={_(
+            msg`Please explain why you think your chats were incorrectly disabled`,
+          )}
+          value={details}
+          onChangeText={setDetails}
+          autoFocus={true}
+          numberOfLines={3}
+          multiline
+          maxLength={300}
+        />
+      </View>
+
+      <View
+        style={
+          gtMobile
+            ? [a.flex_row, a.justify_between]
+            : [{flexDirection: 'column-reverse'}, a.gap_sm]
+        }>
+        <Button
+          testID="backBtn"
+          variant="solid"
+          color="secondary"
+          size="large"
+          onPress={onBack}
+          label={_(msg`Back`)}>
+          <ButtonText>{_(msg`Back`)}</ButtonText>
+        </Button>
+        <Button
+          testID="submitBtn"
+          variant="solid"
+          color="primary"
+          size="large"
+          onPress={onSubmit}
+          label={_(msg`Submit`)}>
+          <ButtonText>{_(msg`Submit`)}</ButtonText>
+          {isPending && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
new file mode 100644
index 000000000..11c071082
--- /dev/null
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -0,0 +1,378 @@
+import React, {useCallback, useState} from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyEmbedRecord,
+  ChatBskyConvoDefs,
+  moderateProfile,
+  ModerationOpts,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useHaptics} from '#/lib/haptics'
+import {decrementBadgeCount} from '#/lib/notifications/notifications'
+import {logEvent} from '#/lib/statsig/statsig'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {
+  postUriToRelativePath,
+  toBskyAppUrl,
+  toShortUrl,
+} from '#/lib/strings/url-helpers'
+import {isNative} from '#/platform/detection'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {TimeElapsed} from '#/view/com/util/TimeElapsed'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import * as tokens from '#/alf/tokens'
+import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
+import {Link} from '#/components/Link'
+import {useMenuControl} from '#/components/Menu'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {Text} from '#/components/Typography'
+
+export let ChatListItem = ({
+  convo,
+}: {
+  convo: ChatBskyConvoDefs.ConvoView
+}): React.ReactNode => {
+  const {currentAccount} = useSession()
+  const moderationOpts = useModerationOpts()
+
+  const otherUser = convo.members.find(
+    member => member.did !== currentAccount?.did,
+  )
+
+  if (!otherUser || !moderationOpts) {
+    return null
+  }
+
+  return (
+    <ChatListItemReady
+      convo={convo}
+      profile={otherUser}
+      moderationOpts={moderationOpts}
+    />
+  )
+}
+
+ChatListItem = React.memo(ChatListItem)
+
+function ChatListItemReady({
+  convo,
+  profile: profileUnshadowed,
+  moderationOpts,
+}: {
+  convo: ChatBskyConvoDefs.ConvoView
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const menuControl = useMenuControl()
+  const {gtMobile} = useBreakpoints()
+  const profile = useProfileShadow(profileUnshadowed)
+  const moderation = React.useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
+  const playHaptic = useHaptics()
+
+  const blockInfo = React.useMemo(() => {
+    const modui = moderation.ui('profileView')
+    const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
+    const listBlocks = blocks.filter(alert => alert.source.type === 'list')
+    const userBlock = blocks.find(alert => alert.source.type === 'user')
+    return {
+      listBlocks,
+      userBlock,
+    }
+  }, [moderation])
+
+  const isDeletedAccount = profile.handle === 'missing.invalid'
+  const displayName = isDeletedAccount
+    ? 'Deleted Account'
+    : sanitizeDisplayName(
+        profile.displayName || profile.handle,
+        moderation.ui('displayName'),
+      )
+
+  const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
+
+  const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
+    let lastMessage = _(msg`No messages yet`)
+    let lastMessageSentAt: string | null = null
+
+    if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
+      const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
+
+      if (convo.lastMessage.text) {
+        if (isFromMe) {
+          lastMessage = _(msg`You: ${convo.lastMessage.text}`)
+        } else {
+          lastMessage = convo.lastMessage.text
+        }
+      } else if (convo.lastMessage.embed) {
+        const defaultEmbeddedContentMessage = _(
+          msg`(contains embedded content)`,
+        )
+
+        if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
+          const embed = convo.lastMessage.embed
+
+          if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
+            const record = embed.record
+            const path = postUriToRelativePath(record.uri, {
+              handle: record.author.handle,
+            })
+            const href = path ? toBskyAppUrl(path) : undefined
+            const short = href
+              ? toShortUrl(href)
+              : defaultEmbeddedContentMessage
+            if (isFromMe) {
+              lastMessage = _(msg`You: ${short}`)
+            } else {
+              lastMessage = short
+            }
+          }
+        } else {
+          if (isFromMe) {
+            lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
+          } else {
+            lastMessage = defaultEmbeddedContentMessage
+          }
+        }
+      }
+
+      lastMessageSentAt = convo.lastMessage.sentAt
+    }
+    if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
+      lastMessage = isDeletedAccount
+        ? _(msg`Conversation deleted`)
+        : _(msg`Message deleted`)
+    }
+
+    return {
+      lastMessage,
+      lastMessageSentAt,
+    }
+  }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
+
+  const [showActions, setShowActions] = useState(false)
+
+  const onMouseEnter = useCallback(() => {
+    setShowActions(true)
+  }, [])
+
+  const onMouseLeave = useCallback(() => {
+    setShowActions(false)
+  }, [])
+
+  const onFocus = useCallback<React.FocusEventHandler>(e => {
+    if (e.nativeEvent.relatedTarget == null) return
+    setShowActions(true)
+  }, [])
+
+  const onPress = useCallback(
+    (e: GestureResponderEvent) => {
+      decrementBadgeCount(convo.unreadCount)
+      if (isDeletedAccount) {
+        e.preventDefault()
+        menuControl.open()
+        return false
+      } else {
+        logEvent('chat:open', {logContext: 'ChatsList'})
+      }
+    },
+    [convo.unreadCount, isDeletedAccount, menuControl],
+  )
+
+  const onLongPress = useCallback(() => {
+    playHaptic()
+    menuControl.open()
+  }, [playHaptic, menuControl])
+
+  return (
+    <View
+      // @ts-expect-error web only
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onMouseLeave}
+      style={[a.relative]}>
+      <View
+        style={[
+          a.z_10,
+          a.absolute,
+          {top: tokens.space.md, left: tokens.space.lg},
+        ]}>
+        <PreviewableUserAvatar
+          profile={profile}
+          size={52}
+          moderation={moderation.ui('avatar')}
+        />
+      </View>
+
+      <Link
+        to={`/messages/${convo.id}`}
+        label={displayName}
+        accessibilityHint={
+          !isDeletedAccount
+            ? _(msg`Go to conversation with ${profile.handle}`)
+            : _(
+                msg`This conversation is with a deleted or a deactivated account. Press for options.`,
+              )
+        }
+        accessibilityActions={
+          isNative
+            ? [
+                {name: 'magicTap', label: _(msg`Open conversation options`)},
+                {name: 'longpress', label: _(msg`Open conversation options`)},
+              ]
+            : undefined
+        }
+        onPress={onPress}
+        onLongPress={isNative ? onLongPress : undefined}
+        onAccessibilityAction={onLongPress}>
+        {({hovered, pressed, focused}) => (
+          <View
+            style={[
+              a.flex_row,
+              isDeletedAccount ? a.align_center : a.align_start,
+              a.flex_1,
+              a.px_lg,
+              a.py_md,
+              a.gap_md,
+              (hovered || pressed || focused) && t.atoms.bg_contrast_25,
+              t.atoms.border_contrast_low,
+            ]}>
+            {/* Avatar goes here */}
+            <View style={{width: 52, height: 52}} />
+
+            <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
+              <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
+                <Text
+                  numberOfLines={1}
+                  style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
+                  <Text
+                    emoji
+                    style={[
+                      a.text_md,
+                      t.atoms.text,
+                      a.font_bold,
+                      {lineHeight: 21},
+                      isDimStyle && t.atoms.text_contrast_medium,
+                    ]}>
+                    {displayName}
+                  </Text>
+                </Text>
+                {lastMessageSentAt && (
+                  <TimeElapsed timestamp={lastMessageSentAt}>
+                    {({timeElapsed}) => (
+                      <Text
+                        style={[
+                          a.text_sm,
+                          {lineHeight: 21},
+                          t.atoms.text_contrast_medium,
+                          web({whiteSpace: 'preserve nowrap'}),
+                        ]}>
+                        {' '}
+                        &middot; {timeElapsed}
+                      </Text>
+                    )}
+                  </TimeElapsed>
+                )}
+                {(convo.muted || moderation.blocked) && (
+                  <Text
+                    style={[
+                      a.text_sm,
+                      {lineHeight: 21},
+                      t.atoms.text_contrast_medium,
+                      web({whiteSpace: 'preserve nowrap'}),
+                    ]}>
+                    {' '}
+                    &middot;{' '}
+                    <BellStroke
+                      size="xs"
+                      style={[t.atoms.text_contrast_medium]}
+                    />
+                  </Text>
+                )}
+              </View>
+
+              {!isDeletedAccount && (
+                <Text
+                  numberOfLines={1}
+                  style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
+                  @{profile.handle}
+                </Text>
+              )}
+
+              <Text
+                emoji
+                numberOfLines={2}
+                style={[
+                  a.text_sm,
+                  a.leading_snug,
+                  convo.unreadCount > 0
+                    ? a.font_bold
+                    : t.atoms.text_contrast_high,
+                  isDimStyle && t.atoms.text_contrast_medium,
+                ]}>
+                {lastMessage}
+              </Text>
+
+              <PostAlerts
+                modui={moderation.ui('contentList')}
+                size="lg"
+                style={[a.pt_xs]}
+              />
+            </View>
+
+            {convo.unreadCount > 0 && (
+              <View
+                style={[
+                  a.absolute,
+                  a.rounded_full,
+                  {
+                    backgroundColor: isDimStyle
+                      ? t.palette.contrast_200
+                      : t.palette.primary_500,
+                    height: 7,
+                    width: 7,
+                    top: 15,
+                    right: 12,
+                  },
+                ]}
+              />
+            )}
+          </View>
+        )}
+      </Link>
+
+      <ConvoMenu
+        convo={convo}
+        profile={profile}
+        control={menuControl}
+        currentScreen="list"
+        showMarkAsRead={convo.unreadCount > 0}
+        hideTrigger={isNative}
+        blockInfo={blockInfo}
+        style={[
+          a.absolute,
+          a.h_full,
+          a.self_end,
+          a.justify_center,
+          {
+            right: tokens.space.lg,
+            opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
+          },
+        ]}
+      />
+    </View>
+  )
+}
diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx
new file mode 100644
index 000000000..21d6e574e
--- /dev/null
+++ b/src/screens/Messages/components/MessageInput.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import {Pressable, TextInput, useWindowDimensions, View} from 'react-native'
+import {
+  useFocusedInputHandler,
+  useReanimatedKeyboardAnimation,
+} from 'react-native-keyboard-controller'
+import Animated, {
+  measure,
+  useAnimatedProps,
+  useAnimatedRef,
+  useAnimatedStyle,
+  useSharedValue,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import Graphemer from 'graphemer'
+
+import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
+import {useHaptics} from '#/lib/haptics'
+import {isIOS} from '#/platform/detection'
+import {
+  useMessageDraft,
+  useSaveMessageDraft,
+} from '#/state/messages/message-drafts'
+import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {useSharedInputStyles} from '#/components/forms/TextField'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+import {useExtractEmbedFromFacets} from './MessageInputEmbed'
+
+const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
+
+export function MessageInput({
+  onSendMessage,
+  hasEmbed,
+  setEmbed,
+  children,
+}: {
+  onSendMessage: (message: string) => void
+  hasEmbed: boolean
+  setEmbed: (embedUrl: string | undefined) => void
+  children?: React.ReactNode
+  openEmojiPicker?: (pos: EmojiPickerPosition) => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const playHaptic = useHaptics()
+  const {getDraft, clearDraft} = useMessageDraft()
+
+  // Input layout
+  const {top: topInset} = useSafeAreaInsets()
+  const {height: windowHeight} = useWindowDimensions()
+  const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
+  const maxHeight = useSharedValue<undefined | number>(undefined)
+  const isInputScrollable = useSharedValue(false)
+
+  const inputStyles = useSharedInputStyles()
+  const [isFocused, setIsFocused] = React.useState(false)
+  const [message, setMessage] = React.useState(getDraft)
+  const inputRef = useAnimatedRef<TextInput>()
+
+  useSaveMessageDraft(message)
+  useExtractEmbedFromFacets(message, setEmbed)
+
+  const onSubmit = React.useCallback(() => {
+    if (!hasEmbed && message.trim() === '') {
+      return
+    }
+    if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
+      Toast.show(_(msg`Message is too long`), 'xmark')
+      return
+    }
+    clearDraft()
+    onSendMessage(message)
+    playHaptic()
+    setMessage('')
+    setEmbed(undefined)
+
+    // 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)
+  }, [
+    hasEmbed,
+    message,
+    clearDraft,
+    onSendMessage,
+    playHaptic,
+    setEmbed,
+    _,
+    inputRef,
+  ])
+
+  useFocusedInputHandler(
+    {
+      onChangeText: () => {
+        'worklet'
+        const measurement = measure(inputRef)
+        if (!measurement) return
+
+        const max = windowHeight - -keyboardHeight.value - topInset - 150
+        const availableSpace = max - measurement.height
+
+        maxHeight.value = max
+        isInputScrollable.value = availableSpace < 30
+      },
+    },
+    [windowHeight, topInset],
+  )
+
+  const animatedStyle = useAnimatedStyle(() => ({
+    maxHeight: maxHeight.value,
+  }))
+
+  const animatedProps = useAnimatedProps(() => ({
+    scrollEnabled: isInputScrollable.value,
+  }))
+
+  return (
+    <View style={[a.px_md, a.pb_sm, a.pt_xs]}>
+      {children}
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          t.atoms.bg_contrast_25,
+          {
+            padding: a.p_sm.padding - 2,
+            paddingLeft: a.p_md.padding - 2,
+            borderWidth: 1,
+            borderRadius: 23,
+            borderColor: 'transparent',
+          },
+          isFocused && inputStyles.chromeFocus,
+        ]}>
+        <AnimatedTextInput
+          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,
+            {paddingBottom: isIOS ? 5 : 0},
+            animatedStyle,
+          ]}
+          keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
+          blurOnSubmit={false}
+          onFocus={() => setIsFocused(true)}
+          onBlur={() => setIsFocused(false)}
+          ref={inputRef}
+          hitSlop={HITSLOP_10}
+          animatedProps={animatedProps}
+        />
+        <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/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx
new file mode 100644
index 000000000..b15cd2492
--- /dev/null
+++ b/src/screens/Messages/components/MessageInput.web.tsx
@@ -0,0 +1,238 @@
+import React from 'react'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import Graphemer from 'graphemer'
+import TextareaAutosize from 'react-textarea-autosize'
+
+import {isSafari, isTouchDevice} from '#/lib/browser'
+import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {
+  useMessageDraft,
+  useSaveMessageDraft,
+} from '#/state/messages/message-drafts'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
+import {
+  Emoji,
+  EmojiPickerPosition,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useSharedInputStyles} from '#/components/forms/TextField'
+import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+import {useExtractEmbedFromFacets} from './MessageInputEmbed'
+
+export function MessageInput({
+  onSendMessage,
+  hasEmbed,
+  setEmbed,
+  children,
+  openEmojiPicker,
+}: {
+  onSendMessage: (message: string) => void
+  hasEmbed: boolean
+  setEmbed: (embedUrl: string | undefined) => void
+  children?: React.ReactNode
+  openEmojiPicker?: (pos: EmojiPickerPosition) => void
+}) {
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const {_} = useLingui()
+  const t = useTheme()
+  const {getDraft, clearDraft} = useMessageDraft()
+  const [message, setMessage] = React.useState(getDraft)
+
+  const inputStyles = useSharedInputStyles()
+  const isComposing = React.useRef(false)
+  const [isFocused, setIsFocused] = React.useState(false)
+  const [isHovered, setIsHovered] = React.useState(false)
+  const [textAreaHeight, setTextAreaHeight] = React.useState(38)
+  const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
+
+  const onSubmit = React.useCallback(() => {
+    if (!hasEmbed && message.trim() === '') {
+      return
+    }
+    if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
+      Toast.show(_(msg`Message is too long`), 'xmark')
+      return
+    }
+    clearDraft()
+    onSendMessage(message)
+    setMessage('')
+    setEmbed(undefined)
+  }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
+
+  const onKeyDown = React.useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      // Don't submit the form when the Japanese or any other IME is composing
+      if (isComposing.current) return
+
+      // see https://github.com/bluesky-social/social-app/issues/4178
+      // see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
+      // see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
+      //
+      // On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
+      // Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
+      // stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
+      //
+      // Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
+      // onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
+      // uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
+      // far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
+      // frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
+      // time gap between the two events firing.
+      if (isSafari && e.key === 'Enter' && e.keyCode === 229) {
+        return
+      }
+
+      if (e.key === 'Enter') {
+        if (e.shiftKey) return
+        e.preventDefault()
+        onSubmit()
+      }
+    },
+    [onSubmit],
+  )
+
+  const onChange = React.useCallback(
+    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      setMessage(e.target.value)
+    },
+    [],
+  )
+
+  const onEmojiInserted = React.useCallback(
+    (emoji: Emoji) => {
+      const position = textAreaRef.current?.selectionStart ?? 0
+      setMessage(
+        message =>
+          message.slice(0, position) + emoji.native + message.slice(position),
+      )
+    },
+    [setMessage],
+  )
+  React.useEffect(() => {
+    textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
+    return () => {
+      textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
+    }
+  }, [onEmojiInserted])
+
+  useSaveMessageDraft(message)
+  useExtractEmbedFromFacets(message, setEmbed)
+
+  return (
+    <View style={a.p_sm}>
+      {children}
+      <View
+        style={[
+          a.flex_row,
+          t.atoms.bg_contrast_25,
+          {
+            paddingRight: a.p_sm.padding - 2,
+            paddingLeft: a.p_sm.padding - 2,
+            borderWidth: 1,
+            borderRadius: 23,
+            borderColor: 'transparent',
+            height: textAreaHeight + 23,
+          },
+          isHovered && inputStyles.chromeHover,
+          isFocused && inputStyles.chromeFocus,
+        ]}
+        // @ts-expect-error web only
+        onMouseEnter={() => setIsHovered(true)}
+        onMouseLeave={() => setIsHovered(false)}>
+        <Button
+          onPress={e => {
+            e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
+              openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
+            })
+          }}
+          style={[
+            a.rounded_full,
+            a.overflow_hidden,
+            a.align_center,
+            a.justify_center,
+            {
+              marginTop: 5,
+              height: 30,
+              width: 30,
+            },
+          ]}
+          label={_(msg`Open emoji picker`)}>
+          {state => (
+            <View
+              style={[
+                a.absolute,
+                a.inset_0,
+                a.align_center,
+                a.justify_center,
+                {
+                  backgroundColor:
+                    state.hovered || state.focused || state.pressed
+                      ? t.atoms.bg.backgroundColor
+                      : undefined,
+                },
+              ]}>
+              <EmojiSmile size="lg" />
+            </View>
+          )}
+        </Button>
+        <TextareaAutosize
+          ref={textAreaRef}
+          style={StyleSheet.flatten([
+            a.flex_1,
+            a.px_sm,
+            a.border_0,
+            t.atoms.text,
+            {
+              paddingTop: 10,
+              backgroundColor: 'transparent',
+              resize: 'none',
+            },
+          ])}
+          maxRows={12}
+          placeholder={_(msg`Write a message`)}
+          defaultValue=""
+          value={message}
+          dirName="ltr"
+          autoFocus={true}
+          onFocus={() => setIsFocused(true)}
+          onBlur={() => setIsFocused(false)}
+          onCompositionStart={() => {
+            isComposing.current = true
+          }}
+          onCompositionEnd={() => {
+            isComposing.current = false
+          }}
+          onHeightChange={height => setTextAreaHeight(height)}
+          onChange={onChange}
+          // On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
+          // in these cases.
+          onKeyDown={isTouchDevice && isTabletOrDesktop ? undefined : onKeyDown}
+        />
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Send message`)}
+          accessibilityHint=""
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {
+              height: 30,
+              width: 30,
+              marginTop: 5,
+              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/components/MessageInputEmbed.tsx b/src/screens/Messages/components/MessageInputEmbed.tsx
new file mode 100644
index 000000000..2d1551019
--- /dev/null
+++ b/src/screens/Messages/components/MessageInputEmbed.tsx
@@ -0,0 +1,219 @@
+import React, {useCallback, useEffect, useMemo, useState} from 'react'
+import {LayoutAnimation, View} from 'react-native'
+import {
+  AppBskyFeedPost,
+  AppBskyRichtextFacet,
+  AtUri,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
+
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {makeProfileLink} from '#/lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {
+  convertBskyAppUrlIfNeeded,
+  isBskyPostUrl,
+  makeRecordUri,
+} from '#/lib/strings/url-helpers'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {usePostQuery} from '#/state/queries/post'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Loader} from '#/components/Loader'
+import * as MediaPreview from '#/components/MediaPreview'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+
+export function useMessageEmbed() {
+  const route =
+    useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
+  const navigation = useNavigation<NavigationProp>()
+  const embedFromParams = route.params.embed
+
+  const [embedUri, setEmbed] = useState(embedFromParams)
+
+  if (embedFromParams && embedUri !== embedFromParams) {
+    setEmbed(embedFromParams)
+  }
+
+  return {
+    embedUri,
+    setEmbed: useCallback(
+      (embedUrl: string | undefined) => {
+        if (!embedUrl) {
+          navigation.setParams({embed: ''})
+          setEmbed(undefined)
+          return
+        }
+
+        if (embedFromParams) return
+
+        const url = convertBskyAppUrlIfNeeded(embedUrl)
+        const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
+        const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
+
+        setEmbed(uri)
+      },
+      [embedFromParams, navigation],
+    ),
+  }
+}
+
+export function useExtractEmbedFromFacets(
+  message: string,
+  setEmbed: (embedUrl: string | undefined) => void,
+) {
+  const rt = new RichTextAPI({text: message})
+  rt.detectFacetsWithoutResolution()
+
+  let uriFromFacet: string | undefined
+
+  for (const facet of rt.facets ?? []) {
+    for (const feature of facet.features) {
+      if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
+        uriFromFacet = feature.uri
+        break
+      }
+    }
+  }
+
+  useEffect(() => {
+    if (uriFromFacet) {
+      setEmbed(uriFromFacet)
+    }
+  }, [uriFromFacet, setEmbed])
+}
+
+export function MessageInputEmbed({
+  embedUri,
+  setEmbed,
+}: {
+  embedUri: string | undefined
+  setEmbed: (embedUrl: string | undefined) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {data: post, status} = usePostQuery(embedUri)
+
+  const moderationOpts = useModerationOpts()
+  const moderation = useMemo(
+    () =>
+      moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
+    [moderationOpts, post],
+  )
+
+  const {rt, record} = useMemo(() => {
+    if (
+      post &&
+      AppBskyFeedPost.isRecord(post.record) &&
+      AppBskyFeedPost.validateRecord(post.record).success
+    ) {
+      return {
+        rt: new RichTextAPI({
+          text: post.record.text,
+          facets: post.record.facets,
+        }),
+        record: post.record,
+      }
+    }
+
+    return {rt: undefined, record: undefined}
+  }, [post])
+
+  if (!embedUri) {
+    return null
+  }
+
+  let content = null
+  switch (status) {
+    case 'pending':
+      content = (
+        <View
+          style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
+          <Loader />
+        </View>
+      )
+      break
+    case 'error':
+      content = (
+        <View
+          style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
+          <Text style={a.text_center}>Could not fetch post</Text>
+        </View>
+      )
+      break
+    case 'success':
+      const itemUrip = new AtUri(post.uri)
+      const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
+
+      if (!post || !moderation || !rt || !record) {
+        return null
+      }
+
+      content = (
+        <View
+          style={[
+            a.flex_1,
+            t.atoms.bg,
+            t.atoms.border_contrast_low,
+            a.rounded_md,
+            a.border,
+            a.p_sm,
+            a.mb_sm,
+          ]}
+          pointerEvents="none">
+          <PostMeta
+            showAvatar
+            author={post.author}
+            moderation={moderation}
+            timestamp={post.indexedAt}
+            postHref={itemHref}
+            style={a.flex_0}
+          />
+          <ContentHider modui={moderation.ui('contentView')}>
+            <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
+            {rt.text && (
+              <View style={a.mt_xs}>
+                <RichText
+                  enableTags
+                  testID="postText"
+                  value={rt}
+                  style={[a.text_sm, t.atoms.text_contrast_high]}
+                  authorHandle={post.author.handle}
+                  numberOfLines={3}
+                />
+              </View>
+            )}
+            <MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
+          </ContentHider>
+        </View>
+      )
+      break
+  }
+
+  return (
+    <View style={[a.flex_row, a.gap_sm]}>
+      {content}
+      <Button
+        label={_(msg`Remove embed`)}
+        onPress={() => {
+          LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+          setEmbed(undefined)
+        }}
+        size="tiny"
+        variant="solid"
+        color="secondary"
+        shape="round">
+        <ButtonIcon icon={X} />
+      </Button>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/components/MessageListError.tsx b/src/screens/Messages/components/MessageListError.tsx
new file mode 100644
index 000000000..6f50948df
--- /dev/null
+++ b/src/screens/Messages/components/MessageListError.tsx
@@ -0,0 +1,61 @@
+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/types'
+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'}}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {description, help, cta} = React.useMemo(() => {
+    return {
+      [ConvoItemError.FirehoseFailed]: {
+        description: _(msg`This chat was disconnected`),
+        help: _(msg`Press to attempt reconnection`),
+        cta: _(msg`Reconnect`),
+      },
+      [ConvoItemError.HistoryFailed]: {
+        description: _(msg`Failed to load past messages`),
+        help: _(msg`Press to retry`),
+        cta: _(msg`Retry`),
+      },
+    }[item.code]
+  }, [_, item.code])
+
+  return (
+    <View style={[a.py_md, a.w_full, a.flex_row, a.justify_center]}>
+      <View
+        style={[
+          a.flex_1,
+          a.flex_row,
+          a.align_center,
+          a.justify_center,
+          a.gap_sm,
+          {maxWidth: 400},
+        ]}>
+        <CircleInfo size="sm" fill={t.palette.negative_400} />
+
+        <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
+          {description} &middot;{' '}
+          {item.retry && (
+            <InlineLinkText
+              to="#"
+              label={help}
+              onPress={e => {
+                e.preventDefault()
+                item.retry?.()
+                return false
+              }}>
+              {cta}
+            </InlineLinkText>
+          )}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
new file mode 100644
index 000000000..b659e98d6
--- /dev/null
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -0,0 +1,454 @@
+import React, {useCallback, useRef} from 'react'
+import {FlatList, LayoutChangeEvent, View} from 'react-native'
+import {
+  KeyboardStickyView,
+  useKeyboardHandler,
+} from 'react-native-keyboard-controller'
+import {
+  runOnJS,
+  scrollTo,
+  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 {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
+
+import {clamp} from '#/lib/numbers'
+import {ScrollProvider} from '#/lib/ScrollContext'
+import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
+import {
+  convertBskyAppUrlIfNeeded,
+  isBskyPostUrl,
+} from '#/lib/strings/url-helpers'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {isWeb} from '#/platform/detection'
+import {isConvoActive, useConvoActive} from '#/state/messages/convo'
+import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
+import {useGetPost} from '#/state/queries/post'
+import {useAgent} from '#/state/session'
+import {
+  EmojiPicker,
+  EmojiPickerState,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
+import {List} from '#/view/com/util/List'
+import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
+import {MessageInput} from '#/screens/Messages/components/MessageInput'
+import {MessageListError} from '#/screens/Messages/components/MessageListError'
+import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
+import {MessageItem} from '#/components/dms/MessageItem'
+import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
+
+function MaybeLoader({isLoading}: {isLoading: boolean}) {
+  return (
+    <View
+      style={{
+        height: 50,
+        width: '100%',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }}>
+      {isLoading && <Loader size="xl" />}
+    </View>
+  )
+}
+
+function renderItem({item}: {item: ConvoItem}) {
+  if (item.type === 'message' || item.type === 'pending-message') {
+    return <MessageItem item={item} />
+  } else if (item.type === 'deleted-message') {
+    return <Text>Deleted message</Text>
+  } else if (item.type === 'error') {
+    return <MessageListError item={item} />
+  }
+
+  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,
+  blocked,
+  footer,
+}: {
+  hasScrolled: boolean
+  setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
+  blocked?: boolean
+  footer?: React.ReactNode
+}) {
+  const convoState = useConvoActive()
+  const agent = useAgent()
+  const getPost = useGetPost()
+  const {embedUri, setEmbed} = useMessageEmbed()
+
+  const flatListRef = useAnimatedRef<FlatList>()
+
+  const [newMessagesPill, setNewMessagesPill] = React.useState({
+    show: false,
+    startContentOffset: 0,
+  })
+
+  const [emojiPickerState, setEmojiPickerState] =
+    React.useState<EmojiPickerState>({
+      isOpen: false,
+      pos: {top: 0, left: 0, right: 0, bottom: 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
+  // the bottom.
+  const isAtBottom = useSharedValue(true)
+
+  // This will be used on web to assist in determining 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 prevContentHeight = useRef(0)
+  const prevItemCount = useRef(0)
+
+  // -- 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
+
+  // 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 - prevContentHeight.current,
+          animated: false,
+        })
+      }
+
+      // 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 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: prevContentHeight.current - 65,
+            animated: true,
+          })
+          setNewMessagesPill({
+            show: true,
+            startContentOffset: prevContentHeight.current - 65,
+          })
+        } else {
+          flatListRef.current?.scrollToOffset({
+            offset: height,
+            animated: hasScrolled && height > prevContentHeight.current,
+          })
+
+          // 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)
+          }
+        }
+      }
+
+      prevContentHeight.current = height
+      prevItemCount.current = convoState.items.length
+      didBackground.current = false
+    },
+    [
+      hasScrolled,
+      setHasScrolled,
+      convoState.isFetchingHistory,
+      convoState.items.length,
+      // these are stable
+      flatListRef,
+      isAtTop.value,
+      isAtBottom.value,
+      layoutHeight.value,
+    ],
+  )
+
+  const onStartReached = useCallback(() => {
+    if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
+      convoState.fetchMessageHistory()
+    }
+  }, [convoState, hasScrolled, layoutHeight.value])
+
+  const onScroll = React.useCallback(
+    (e: ReanimatedScrollEvent) => {
+      'worklet'
+      layoutHeight.value = e.layoutMeasurement.height
+      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
+
+      if (
+        newMessagesPill.show &&
+        (e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
+          isAtBottom.value)
+      ) {
+        runOnJS(setNewMessagesPill)({
+          show: false,
+          startContentOffset: 0,
+        })
+      }
+    },
+    [layoutHeight, newMessagesPill, isAtBottom, isAtTop],
+  )
+
+  // -- Keyboard animation handling
+  const {bottom: bottomInset} = useSafeAreaInsets()
+  const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
+
+  const keyboardHeight = useSharedValue(0)
+  const keyboardIsOpening = useSharedValue(false)
+
+  // In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event.
+  // We use this value to keep track of when we want to disable the animation.
+  const layoutScrollWithoutAnimation = useSharedValue(false)
+
+  useKeyboardHandler({
+    onStart: e => {
+      'worklet'
+      // Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should
+      // just update the height here instead of having the `onMove` event do it (that event will not fire!)
+      if (e.duration === 0) {
+        layoutScrollWithoutAnimation.value = true
+        keyboardHeight.value = e.height
+      } else {
+        keyboardIsOpening.value = true
+      }
+    },
+    onMove: e => {
+      'worklet'
+      keyboardHeight.value = e.height
+      if (e.height > bottomOffset) {
+        scrollTo(flatListRef, 0, 1e7, false)
+      }
+    },
+    onEnd: () => {
+      'worklet'
+      keyboardIsOpening.value = false
+    },
+  })
+
+  const animatedListStyle = useAnimatedStyle(() => ({
+    marginBottom:
+      keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
+  }))
+
+  // -- Message sending
+  const onSendMessage = useCallback(
+    async (text: string) => {
+      let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
+
+      // detect facets without resolution first - this is used to see if there's
+      // any post links in the text that we can embed. We do this first because
+      // we want to remove the post link from the text, re-trim, then detect facets
+      rt.detectFacetsWithoutResolution()
+
+      let embed: AppBskyEmbedRecord.Main | undefined
+
+      if (embedUri) {
+        try {
+          const post = await getPost({uri: embedUri})
+          if (post) {
+            embed = {
+              $type: 'app.bsky.embed.record',
+              record: {
+                uri: post.uri,
+                cid: post.cid,
+              },
+            }
+
+            // look for the embed uri in the facets, so we can remove it from the text
+            const postLinkFacet = rt.facets?.find(facet => {
+              return facet.features.find(feature => {
+                if (AppBskyRichtextFacet.isLink(feature)) {
+                  if (isBskyPostUrl(feature.uri)) {
+                    const url = convertBskyAppUrlIfNeeded(feature.uri)
+                    const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
+
+                    // this might have a handle instead of a DID
+                    // so just compare the rkey - not particularly dangerous
+                    return post.uri.endsWith(rkey)
+                  }
+                }
+                return false
+              })
+            })
+
+            if (postLinkFacet) {
+              const isAtStart = postLinkFacet.index.byteStart === 0
+              const isAtEnd =
+                postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
+
+              // remove the post link from the text
+              if (isAtStart || isAtEnd) {
+                rt.delete(
+                  postLinkFacet.index.byteStart,
+                  postLinkFacet.index.byteEnd,
+                )
+              }
+
+              rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
+            }
+          }
+        } catch (error) {
+          logger.error('Failed to get post as quote for DM', {error})
+        }
+      }
+
+      await rt.detectFacets(agent)
+
+      rt = shortenLinks(rt)
+      rt = stripInvalidMentions(rt)
+
+      if (!hasScrolled) {
+        setHasScrolled(true)
+      }
+
+      convoState.sendMessage({
+        text: rt.text,
+        facets: rt.facets,
+        embed,
+      })
+    },
+    [agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
+  )
+
+  // -- List layout changes (opening emoji keyboard, etc.)
+  const onListLayout = React.useCallback(
+    (e: LayoutChangeEvent) => {
+      layoutHeight.value = e.nativeEvent.layout.height
+
+      if (isWeb || !keyboardIsOpening.value) {
+        flatListRef.current?.scrollToEnd({
+          animated: !layoutScrollWithoutAnimation.value,
+        })
+        layoutScrollWithoutAnimation.value = false
+      }
+    },
+    [
+      flatListRef,
+      keyboardIsOpening.value,
+      layoutScrollWithoutAnimation,
+      layoutHeight,
+    ],
+  )
+
+  const scrollToEndOnPress = React.useCallback(() => {
+    flatListRef.current?.scrollToOffset({
+      offset: prevContentHeight.current,
+      animated: true,
+    })
+  }, [flatListRef])
+
+  return (
+    <>
+      {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
+      <ScrollProvider onScroll={onScroll}>
+        <List
+          ref={flatListRef}
+          data={convoState.items}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          disableFullWindowScroll={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: 0,
+          }}
+          removeClippedSubviews={false}
+          sideBorders={false}
+          onContentSizeChange={onContentSizeChange}
+          onLayout={onListLayout}
+          onStartReached={onStartReached}
+          onScrollToIndexFailed={onScrollToIndexFailed}
+          scrollEventThrottle={100}
+          ListHeaderComponent={
+            <MaybeLoader isLoading={convoState.isFetchingHistory} />
+          }
+        />
+      </ScrollProvider>
+      <KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
+        {convoState.status === ConvoStatus.Disabled ? (
+          <ChatDisabled />
+        ) : blocked ? (
+          footer
+        ) : (
+          <>
+            {isConvoActive(convoState) &&
+              !convoState.isFetchingHistory &&
+              convoState.items.length === 0 && <ChatEmptyPill />}
+            <MessageInput
+              onSendMessage={onSendMessage}
+              hasEmbed={!!embedUri}
+              setEmbed={setEmbed}
+              openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
+              <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
+            </MessageInput>
+          </>
+        )}
+      </KeyboardStickyView>
+
+      {isWeb && (
+        <EmojiPicker
+          pinToTop
+          state={emojiPickerState}
+          close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
+        />
+      )}
+
+      {newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
+    </>
+  )
+}