about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/Navigation.tsx5
-rw-r--r--src/components/Prompt.tsx4
-rw-r--r--src/components/dms/BlockedByListDialog.tsx62
-rw-r--r--src/components/dms/ConvoMenu.tsx104
-rw-r--r--src/components/dms/LeaveConvoPrompt.tsx55
-rw-r--r--src/components/dms/MessageItem.tsx2
-rw-r--r--src/components/dms/MessagesListBlockedFooter.tsx131
-rw-r--r--src/components/dms/MessagesListHeader.tsx194
-rw-r--r--src/components/dms/ReportConversationPrompt.tsx27
-rw-r--r--src/lib/routes/types.ts6
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx50
-rw-r--r--src/screens/Messages/Conversation/index.tsx228
-rw-r--r--src/screens/Messages/List/ChatListItem.tsx13
13 files changed, 600 insertions, 281 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f68f8ed66..7abfaec08 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -464,7 +464,10 @@ function MessagesTabNavigator() {
       <MessagesTab.Screen
         name="Messages"
         getComponent={() => MessagesScreen}
-        options={{requireAuth: true}}
+        options={({route}) => ({
+          requireAuth: true,
+          animationTypeForReplace: route.params?.animation ?? 'push',
+        })}
       />
       {commonScreens(MessagesTab as typeof HomeTab)}
     </MessagesTab.Navigator>
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 92e848e8e..d05cab5ab 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -172,6 +172,7 @@ export function Basic({
   confirmButtonCta,
   onConfirm,
   confirmButtonColor,
+  showCancel = true,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
   title: string
@@ -187,6 +188,7 @@ export function Basic({
    */
   onConfirm: () => void
   confirmButtonColor?: ButtonColor
+  showCancel?: boolean
 }>) {
   return (
     <Outer control={control} testID="confirmModal">
@@ -199,7 +201,7 @@ export function Basic({
           color={confirmButtonColor}
           testID="confirmBtn"
         />
-        <Cancel cta={cancelButtonCta} />
+        {showCancel && <Cancel cta={cancelButtonCta} />}
       </Actions>
     </Outer>
   )
diff --git a/src/components/dms/BlockedByListDialog.tsx b/src/components/dms/BlockedByListDialog.tsx
new file mode 100644
index 000000000..a27701605
--- /dev/null
+++ b/src/components/dms/BlockedByListDialog.tsx
@@ -0,0 +1,62 @@
+import React from 'react'
+import {View} from 'react-native'
+import {ModerationCause} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {listUriToHref} from 'lib/strings/url-helpers'
+import {atoms as a, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {InlineLinkText} from '#/components/Link'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+
+export function BlockedByListDialog({
+  control,
+  listBlocks,
+}: {
+  control: DialogControlProps
+  listBlocks: ModerationCause[]
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <Prompt.Outer control={control} testID="blockedByListDialog">
+      <Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
+
+      <View style={[a.gap_sm, a.pb_lg]}>
+        <Text
+          selectable
+          style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
+          {_(
+            msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
+          )}{' '}
+        </Text>
+
+        <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
+          {_(msg`Lists blocking this user:`)}{' '}
+          {listBlocks.map((block, i) =>
+            block.source.type === 'list' ? (
+              <React.Fragment key={block.source.list.uri}>
+                {i === 0 ? null : ', '}
+                <InlineLinkText
+                  to={listUriToHref(block.source.list.uri)}
+                  style={[a.text_md, a.leading_snug]}>
+                  {block.source.list.name}
+                </InlineLinkText>
+              </React.Fragment>
+            ) : null,
+          )}
+        </Text>
+      </View>
+
+      <Prompt.Actions>
+        <Prompt.Action cta={_(msg`I understand`)} onPress={() => {}} />
+      </Prompt.Actions>
+
+      <Dialog.Close />
+    </Prompt.Outer>
+  )
+}
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index cf1dbc171..0e5cd12bf 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -3,25 +3,25 @@ import {Keyboard, Pressable, View} from 'react-native'
 import {
   AppBskyActorDefs,
   ChatBskyConvoDefs,
-  ModerationDecision,
+  ModerationCause,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
 import {NavigationProp} from '#/lib/routes/types'
-import {listUriToHref} from '#/lib/strings/url-helpers'
 import {Shadow} from '#/state/cache/types'
 import {
   useConvoQuery,
   useMarkAsReadMutation,
 } from '#/state/queries/messages/conversation'
-import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
 import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
 import {useProfileBlockMutationQueue} from '#/state/queries/profile'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
-import * as Dialog from '#/components/Dialog'
+import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
+import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
+import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
 import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
@@ -30,10 +30,8 @@ import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Perso
 import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
-import {InlineLinkText} from '#/components/Link'
 import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
-import {Text} from '#/components/Typography'
 import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
 
 let ConvoMenu = ({
@@ -44,7 +42,7 @@ let ConvoMenu = ({
   showMarkAsRead,
   hideTrigger,
   triggerOpacity,
-  moderation,
+  blockInfo,
 }: {
   convo: ChatBskyConvoDefs.ConvoView
   profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
@@ -53,7 +51,10 @@ let ConvoMenu = ({
   showMarkAsRead?: boolean
   hideTrigger?: boolean
   triggerOpacity?: number
-  moderation: ModerationDecision
+  blockInfo: {
+    listBlocks: ModerationCause[]
+    userBlock?: ModerationCause
+  }
 }): React.ReactNode => {
   const navigation = useNavigation<NavigationProp>()
   const {_} = useLingui()
@@ -62,17 +63,9 @@ let ConvoMenu = ({
   const reportControl = Prompt.usePromptControl()
   const blockedByListControl = Prompt.usePromptControl()
   const {mutate: markAsRead} = useMarkAsReadMutation()
-  const modui = moderation.ui('profileView')
-  const {listBlocks, userBlock} = React.useMemo(() => {
-    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,
-    }
-  }, [modui])
-  const isBlocking = !!userBlock || !!listBlocks.length
+
+  const {listBlocks, userBlock} = blockInfo
+  const isBlocking = userBlock || !!listBlocks.length
 
   const {data: convo} = useConvoQuery(initialConvo)
 
@@ -108,17 +101,6 @@ let ConvoMenu = ({
     }
   }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
 
-  const {mutate: leaveConvo} = useLeaveConvo(convo?.id, {
-    onSuccess: () => {
-      if (currentScreen === 'conversation') {
-        navigation.replace('Messages')
-      }
-    },
-    onError: () => {
-      Toast.show(_(msg`Could not leave chat`))
-    },
-  })
-
   return (
     <>
       <Menu.Root control={control}>
@@ -218,67 +200,19 @@ let ConvoMenu = ({
         </Menu.Outer>
       </Menu.Root>
 
-      <Prompt.Basic
+      <LeaveConvoPrompt
         control={leaveConvoControl}
-        title={_(msg`Leave conversation`)}
-        description={_(
-          msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
-        )}
-        confirmButtonCta={_(msg`Leave`)}
-        confirmButtonColor="negative"
-        onConfirm={() => leaveConvo()}
+        convoId={convo.id}
+        currentScreen={currentScreen}
       />
-
-      <Prompt.Basic
-        control={reportControl}
-        title={_(msg`Report conversation`)}
-        description={_(
-          msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
-        )}
-        confirmButtonCta={_(msg`I understand`)}
-        onConfirm={noop}
+      <ReportConversationPrompt control={reportControl} />
+      <BlockedByListDialog
+        control={blockedByListControl}
+        listBlocks={listBlocks}
       />
-
-      <Prompt.Outer control={blockedByListControl} testID="blockedByListDialog">
-        <Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
-
-        <View style={[a.gap_sm, a.pb_lg]}>
-          <Text
-            selectable
-            style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
-            {_(
-              msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
-            )}{' '}
-          </Text>
-
-          <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
-            {_(msg`Lists blocking this user:`)}{' '}
-            {listBlocks.map((block, i) =>
-              block.source.type === 'list' ? (
-                <React.Fragment key={block.source.list.uri}>
-                  {i === 0 ? null : ', '}
-                  <InlineLinkText
-                    to={listUriToHref(block.source.list.uri)}
-                    style={[a.text_md, a.leading_snug]}>
-                    {block.source.list.name}
-                  </InlineLinkText>
-                </React.Fragment>
-              ) : null,
-            )}
-          </Text>
-        </View>
-
-        <Prompt.Actions>
-          <Prompt.Cancel cta={_(msg`I understand`)} />
-        </Prompt.Actions>
-
-        <Dialog.Close />
-      </Prompt.Outer>
     </>
   )
 }
 ConvoMenu = React.memo(ConvoMenu)
 
 export {ConvoMenu}
-
-function noop() {}
diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx
new file mode 100644
index 000000000..1c42dbca0
--- /dev/null
+++ b/src/components/dms/LeaveConvoPrompt.tsx
@@ -0,0 +1,55 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from 'lib/routes/types'
+import {isNative} from 'platform/detection'
+import {useLeaveConvo} from 'state/queries/messages/leave-conversation'
+import * as Toast from 'view/com/util/Toast'
+import {DialogOuterProps} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+
+export function LeaveConvoPrompt({
+  control,
+  convoId,
+  currentScreen,
+}: {
+  control: DialogOuterProps['control']
+  convoId: string
+  currentScreen: 'list' | 'conversation'
+}) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const {mutate: leaveConvo} = useLeaveConvo(convoId, {
+    onSuccess: () => {
+      if (currentScreen === 'conversation') {
+        navigation.replace(
+          'Messages',
+          isNative
+            ? {
+                animation: 'pop',
+              }
+            : {},
+        )
+      }
+    },
+    onError: () => {
+      Toast.show(_(msg`Could not leave chat`))
+    },
+  })
+
+  return (
+    <Prompt.Basic
+      control={control}
+      title={_(msg`Leave conversation`)}
+      description={_(
+        msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
+      )}
+      confirmButtonCta={_(msg`Leave`)}
+      confirmButtonColor="negative"
+      onConfirm={leaveConvo}
+    />
+  )
+}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index f456fa474..c5ff81091 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -75,7 +75,7 @@ let MessageItem = ({
   }, [message.text, message.facets])
 
   return (
-    <View>
+    <View style={[isFromSelf ? a.mr_md : a.ml_md]}>
       <ActionsWrapper isFromSelf={isFromSelf} message={message}>
         <View
           style={[
diff --git a/src/components/dms/MessagesListBlockedFooter.tsx b/src/components/dms/MessagesListBlockedFooter.tsx
new file mode 100644
index 000000000..a018b8623
--- /dev/null
+++ b/src/components/dms/MessagesListBlockedFooter.tsx
@@ -0,0 +1,131 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationCause} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useProfileBlockMutationQueue} from 'state/queries/profile'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
+import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
+import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
+import {Text} from '#/components/Typography'
+
+export function MessagesListBlockedFooter({
+  recipient: initialRecipient,
+  convoId,
+  hasMessages,
+  blockInfo,
+}: {
+  recipient: AppBskyActorDefs.ProfileViewBasic
+  convoId: string
+  hasMessages: boolean
+  blockInfo: {
+    listBlocks: ModerationCause[]
+    userBlock: ModerationCause | undefined
+  }
+}) {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const {_} = useLingui()
+  const recipient = useProfileShadow(initialRecipient)
+  const [__, queueUnblock] = useProfileBlockMutationQueue(recipient)
+
+  const leaveConvoControl = useDialogControl()
+  const reportControl = useDialogControl()
+  const blockedByListControl = useDialogControl()
+
+  const {listBlocks, userBlock} = blockInfo
+  const isBlocking = !!userBlock || !!listBlocks.length
+
+  const onUnblockPress = React.useCallback(() => {
+    if (listBlocks.length) {
+      blockedByListControl.open()
+    } else {
+      queueUnblock()
+    }
+  }, [blockedByListControl, listBlocks, queueUnblock])
+
+  return (
+    <View style={[hasMessages && a.pt_md, a.pb_xl, a.gap_lg]}>
+      <Divider />
+      <Text style={[a.text_md, a.font_bold, a.text_center]}>
+        {isBlocking ? (
+          <Trans>You have blocked this user</Trans>
+        ) : (
+          <Trans>This user has blocked you</Trans>
+        )}
+      </Text>
+
+      <View style={[a.flex_row, a.justify_between, a.gap_lg, a.px_md]}>
+        <Button
+          label={_(msg`Leave chat`)}
+          color="secondary"
+          variant="solid"
+          size="small"
+          style={[a.flex_1]}
+          onPress={leaveConvoControl.open}>
+          <ButtonText style={{color: t.palette.negative_500}}>
+            <Trans>Leave chat</Trans>
+          </ButtonText>
+        </Button>
+        <Button
+          label={_(msg`Report`)}
+          color="secondary"
+          variant="solid"
+          size="small"
+          style={[a.flex_1]}
+          onPress={reportControl.open}>
+          <ButtonText style={{color: t.palette.negative_500}}>
+            <Trans>Report</Trans>
+          </ButtonText>
+        </Button>
+        {isBlocking && gtMobile && (
+          <Button
+            label={_(msg`Unblock`)}
+            color="secondary"
+            variant="solid"
+            size="small"
+            style={[a.flex_1]}
+            onPress={onUnblockPress}>
+            <ButtonText style={{color: t.palette.primary_500}}>
+              <Trans>Unblock</Trans>
+            </ButtonText>
+          </Button>
+        )}
+      </View>
+      {isBlocking && !gtMobile && (
+        <View style={[a.flex_row, a.justify_center, a.px_md]}>
+          <Button
+            label={_(msg`Unblock`)}
+            color="secondary"
+            variant="solid"
+            size="small"
+            style={[a.flex_1]}
+            onPress={onUnblockPress}>
+            <ButtonText style={{color: t.palette.primary_500}}>
+              <Trans>Unblock</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      )}
+
+      <LeaveConvoPrompt
+        control={leaveConvoControl}
+        currentScreen="conversation"
+        convoId={convoId}
+      />
+
+      <ReportConversationPrompt control={reportControl} />
+
+      <BlockedByListDialog
+        control={blockedByListControl}
+        listBlocks={listBlocks}
+      />
+    </View>
+  )
+}
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
new file mode 100644
index 000000000..a6dff4032
--- /dev/null
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -0,0 +1,194 @@
+import React, {useCallback} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  ModerationCause,
+  ModerationDecision,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {BACK_HITSLOP} from 'lib/constants'
+import {makeProfileLink} from 'lib/routes/links'
+import {NavigationProp} from 'lib/routes/types'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {isWeb} from 'platform/detection'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {isConvoActive, useConvo} from 'state/messages/convo'
+import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+const PFP_SIZE = isWeb ? 40 : 34
+
+export let MessagesListHeader = ({
+  profile,
+  moderation,
+  blockInfo,
+}: {
+  profile?: AppBskyActorDefs.ProfileViewBasic
+  moderation?: ModerationDecision
+  blockInfo?: {
+    listBlocks: ModerationCause[]
+    userBlock?: ModerationCause
+  }
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtTablet} = useBreakpoints()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = useCallback(() => {
+    if (isWeb) {
+      navigation.replace('Messages', {})
+    } else {
+      navigation.goBack()
+    }
+  }, [navigation])
+
+  return (
+    <View
+      style={[
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.border_b,
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        gtTablet ? a.pl_lg : a.pl_xl,
+        a.pr_lg,
+        a.py_sm,
+      ]}>
+      {!gtTablet && (
+        <TouchableOpacity
+          testID="conversationHeaderBackBtn"
+          onPress={onPressBack}
+          hitSlop={BACK_HITSLOP}
+          style={{width: 30, height: 30}}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <FontAwesomeIcon
+            size={18}
+            icon="angle-left"
+            style={{
+              marginTop: 6,
+            }}
+            color={t.atoms.text.color}
+          />
+        </TouchableOpacity>
+      )}
+
+      {profile && moderation && blockInfo ? (
+        <HeaderReady
+          profile={profile}
+          moderation={moderation}
+          blockInfo={blockInfo}
+        />
+      ) : (
+        <>
+          <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
+            <View
+              style={[
+                {width: PFP_SIZE, height: PFP_SIZE},
+                a.rounded_full,
+                t.atoms.bg_contrast_25,
+              ]}
+            />
+            <View style={a.gap_xs}>
+              <View
+                style={[
+                  {width: 120, height: 16},
+                  a.rounded_xs,
+                  t.atoms.bg_contrast_25,
+                  a.mt_xs,
+                ]}
+              />
+              <View
+                style={[
+                  {width: 175, height: 12},
+                  a.rounded_xs,
+                  t.atoms.bg_contrast_25,
+                ]}
+              />
+            </View>
+          </View>
+
+          <View style={{width: 30}} />
+        </>
+      )}
+    </View>
+  )
+}
+MessagesListHeader = React.memo(MessagesListHeader)
+
+function HeaderReady({
+  profile: profileUnshadowed,
+  moderation,
+  blockInfo,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderation: ModerationDecision
+  blockInfo: {
+    listBlocks: ModerationCause[]
+    userBlock?: ModerationCause
+  }
+}) {
+  const t = useTheme()
+  const convoState = useConvo()
+  const profile = useProfileShadow(profileUnshadowed)
+
+  const isDeletedAccount = profile?.handle === 'missing.invalid'
+  const displayName = isDeletedAccount
+    ? 'Deleted Account'
+    : sanitizeDisplayName(
+        profile.displayName || profile.handle,
+        moderation.ui('displayName'),
+      )
+
+  return (
+    <>
+      <Link
+        style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
+        to={makeProfileLink(profile)}>
+        <PreviewableUserAvatar
+          size={PFP_SIZE}
+          profile={profile}
+          moderation={moderation.ui('avatar')}
+          disableHoverCard={moderation.blocked}
+        />
+        <View style={a.flex_1}>
+          <Text
+            style={[a.text_md, a.font_bold, web(a.leading_normal)]}
+            numberOfLines={1}>
+            {displayName}
+          </Text>
+          {!isDeletedAccount && (
+            <Text
+              style={[
+                t.atoms.text_contrast_medium,
+                a.text_sm,
+                web([a.leading_normal, {marginTop: -2}]),
+              ]}
+              numberOfLines={1}>
+              @{profile.handle}
+            </Text>
+          )}
+        </View>
+      </Link>
+
+      {isConvoActive(convoState) && (
+        <ConvoMenu
+          convo={convoState.convo}
+          profile={profile}
+          currentScreen="conversation"
+          blockInfo={blockInfo}
+        />
+      )}
+    </>
+  )
+}
diff --git a/src/components/dms/ReportConversationPrompt.tsx b/src/components/dms/ReportConversationPrompt.tsx
new file mode 100644
index 000000000..610cfbcf9
--- /dev/null
+++ b/src/components/dms/ReportConversationPrompt.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {DialogControlProps} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+
+export function ReportConversationPrompt({
+  control,
+}: {
+  control: DialogControlProps
+}) {
+  const {_} = useLingui()
+
+  return (
+    <Prompt.Basic
+      control={control}
+      title={_(msg`Report conversation`)}
+      description={_(
+        msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
+      )}
+      confirmButtonCta={_(msg`I understand`)}
+      onConfirm={() => {}}
+      showCancel={false}
+    />
+  )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 31133cb1b..5011aafd7 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
 }
 
 export type MessagesTabNavigatorParams = CommonNavigatorParams & {
-  Messages: {pushToConversation?: string}
+  Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
 }
 
 export type FlatNavigatorParams = CommonNavigatorParams & {
@@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Feeds: undefined
   Notifications: undefined
   Hashtag: {tag: string; author?: string}
-  Messages: {pushToConversation?: string}
+  Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
@@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   MyProfileTab: undefined
   Hashtag: {tag: string; author?: string}
   MessagesTab: undefined
-  Messages: undefined
+  Messages: {animation?: 'push' | 'pop'}
 }
 
 // NOTE
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index d36fac8ae..ef0cc55d2 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -23,7 +23,7 @@ import {isWeb} from 'platform/detection'
 import {List} from 'view/com/util/List'
 import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
 import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
-import {atoms as a, useBreakpoints} from '#/alf'
+import {atoms as a} from '#/alf'
 import {MessageItem} from '#/components/dms/MessageItem'
 import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
 import {Loader} from '#/components/Loader'
@@ -66,12 +66,17 @@ function onScrollToIndexFailed() {
 export function MessagesList({
   hasScrolled,
   setHasScrolled,
+  blocked,
+  footer,
 }: {
   hasScrolled: boolean
   setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
+  blocked?: boolean
+  footer?: React.ReactNode
 }) {
-  const convo = useConvoActive()
+  const convoState = useConvoActive()
   const {getAgent} = useAgent()
+
   const flatListRef = useAnimatedRef<FlatList>()
 
   const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
@@ -81,7 +86,7 @@ export function MessagesList({
   // the bottom.
   const isAtBottom = useSharedValue(true)
 
-  // This will be used on web to assist in determing if we need to maintain the content offset
+  // 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
@@ -126,11 +131,11 @@ export function MessagesList({
         if (
           hasScrolled &&
           height - contentHeight.value > layoutHeight.value - 50 &&
-          convo.items.length - prevItemCount.current > 1
+          convoState.items.length - prevItemCount.current > 1
         ) {
           newOffset = contentHeight.value - 50
           setShowNewMessagesPill(true)
-        } else if (!hasScrolled && !convo.isFetchingHistory) {
+        } else if (!hasScrolled && !convoState.isFetchingHistory) {
           setHasScrolled(true)
         }
 
@@ -141,12 +146,12 @@ export function MessagesList({
         isMomentumScrolling.value = true
       }
       contentHeight.value = height
-      prevItemCount.current = convo.items.length
+      prevItemCount.current = convoState.items.length
     },
     [
       hasScrolled,
-      convo.items.length,
-      convo.isFetchingHistory,
+      convoState.items.length,
+      convoState.isFetchingHistory,
       setHasScrolled,
       // all of these are stable
       contentHeight,
@@ -161,9 +166,9 @@ export function MessagesList({
 
   const onStartReached = useCallback(() => {
     if (hasScrolled) {
-      convo.fetchMessageHistory()
+      convoState.fetchMessageHistory()
     }
-  }, [convo, hasScrolled])
+  }, [convoState, hasScrolled])
 
   const onSendMessage = useCallback(
     async (text: string) => {
@@ -182,12 +187,12 @@ export function MessagesList({
         return true
       })
 
-      convo.sendMessage({
+      convoState.sendMessage({
         text: rt.text,
         facets: rt.facets,
       })
     },
-    [convo, getAgent],
+    [convoState, getAgent],
   )
 
   const onScroll = React.useCallback(
@@ -225,11 +230,9 @@ export function MessagesList({
 
   // -- Keyboard animation handling
   const animatedKeyboard = useAnimatedKeyboard()
-  const {gtMobile} = useBreakpoints()
   const {bottom: bottomInset} = useSafeAreaInsets()
   const nativeBottomBarHeight = isIOS ? 42 : 60
-  const bottomOffset =
-    isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight
+  const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
 
   // 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
@@ -268,11 +271,10 @@ export function MessagesList({
       <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
         <List
           ref={flatListRef}
-          data={convo.items}
+          data={convoState.items}
           renderItem={renderItem}
           keyExtractor={keyExtractor}
           containWeb={true}
-          contentContainerStyle={[a.px_md]}
           disableVirtualization={true}
           // The extra two items account for the header and the footer components
           initialNumToRender={isNative ? 32 : 62}
@@ -289,14 +291,18 @@ export function MessagesList({
           onScrollToIndexFailed={onScrollToIndexFailed}
           scrollEventThrottle={100}
           ListHeaderComponent={
-            <MaybeLoader isLoading={convo.isFetchingHistory} />
+            <MaybeLoader isLoading={convoState.isFetchingHistory} />
           }
         />
       </ScrollProvider>
-      <MessageInput
-        onSendMessage={onSendMessage}
-        scrollToEnd={scrollToEndNow}
-      />
+      {!blocked ? (
+        <MessageInput
+          onSendMessage={onSendMessage}
+          scrollToEnd={scrollToEndNow}
+        />
+      ) : (
+        footer
+      )}
       {showNewMessagesPill && <NewMessagesPill />}
     </Animated.View>
   )
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 2c42ed16d..0fe4138bb 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -1,35 +1,28 @@
 import React, {useCallback} from 'react'
-import {TouchableOpacity, View} from 'react-native'
+import {View} from 'react-native'
 import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
-import {makeProfileLink} from '#/lib/routes/links'
-import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
+import {CommonNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useCurrentConvoId} from '#/state/messages/current-convo-id'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useProfileQuery} from '#/state/queries/profile'
-import {BACK_HITSLOP} from 'lib/constants'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {isWeb} from 'platform/detection'
+import {useProfileShadow} from 'state/cache/profile-shadow'
 import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
 import {ConvoStatus} from 'state/messages/convo/types'
 import {useSetMinimalShellMode} from 'state/shell'
-import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
 import {CenteredView} from 'view/com/util/Views'
 import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
-import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
-import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
+import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
 import {Error} from '#/components/Error'
-import {Link} from '#/components/Link'
-import {ListMaybePlaceholder} from '#/components/Lists'
 import {Loader} from '#/components/Loader'
-import {Text} from '#/components/Typography'
 import {ClipClopGate} from '../gate'
 
 type Props = NativeStackScreenProps<
@@ -73,6 +66,11 @@ function Inner() {
   const convoState = useConvo()
   const {_} = useLingui()
 
+  const moderationOpts = useModerationOpts()
+  const {data: recipient} = useProfileQuery({
+    did: convoState.recipients?.[0].did,
+  })
+
   // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
   // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
   // empty. So, we also check for that possible state as well and render once we can.
@@ -86,7 +84,7 @@ function Inner() {
   if (convoState.status === ConvoStatus.Error) {
     return (
       <CenteredView style={a.flex_1} sideBorders>
-        <Header />
+        <MessagesListHeader />
         <Error
           title={_(msg`Something went wrong`)}
           message={_(msg`We couldn't load this conversation`)}
@@ -96,20 +94,21 @@ function Inner() {
     )
   }
 
-  /*
-   * Any other convo states (atm) are "ready" states
-   */
   return (
     <CenteredView style={[a.flex_1]} sideBorders>
-      <Header profile={convoState.recipients?.[0]} />
+      {!readyToShow && <MessagesListHeader />}
       <View style={[a.flex_1]}>
-        {isConvoActive(convoState) ? (
-          <MessagesList
+        {moderationOpts && recipient ? (
+          <InnerReady
+            moderationOpts={moderationOpts}
+            recipient={recipient}
             hasScrolled={hasScrolled}
             setHasScrolled={setHasScrolled}
           />
         ) : (
-          <ListMaybePlaceholder isLoading />
+          <>
+            <View style={[a.align_center, a.gap_sm, a.flex_1]} />
+          </>
         )}
         {!readyToShow && (
           <View
@@ -132,160 +131,55 @@ function Inner() {
   )
 }
 
-const PFP_SIZE = isWeb ? 40 : 34
-
-let Header = ({
-  profile: initialProfile,
-}: {
-  profile?: AppBskyActorDefs.ProfileViewBasic
-}): React.ReactNode => {
-  const t = useTheme()
-  const {_} = useLingui()
-  const {gtTablet} = useBreakpoints()
-  const navigation = useNavigation<NavigationProp>()
-  const moderationOpts = useModerationOpts()
-  const {data: profile} = useProfileQuery({did: initialProfile?.did})
-
-  const onPressBack = useCallback(() => {
-    if (isWeb) {
-      navigation.replace('Messages')
-    } else {
-      navigation.goBack()
-    }
-  }, [navigation])
-
-  return (
-    <View
-      style={[
-        t.atoms.bg,
-        t.atoms.border_contrast_low,
-        a.border_b,
-        a.flex_row,
-        a.align_center,
-        a.gap_sm,
-        gtTablet ? a.pl_lg : a.pl_xl,
-        a.pr_lg,
-        a.py_sm,
-      ]}>
-      {!gtTablet && (
-        <TouchableOpacity
-          testID="conversationHeaderBackBtn"
-          onPress={onPressBack}
-          hitSlop={BACK_HITSLOP}
-          style={{width: 30, height: 30}}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Back`)}
-          accessibilityHint="">
-          <FontAwesomeIcon
-            size={18}
-            icon="angle-left"
-            style={{
-              marginTop: 6,
-            }}
-            color={t.atoms.text.color}
-          />
-        </TouchableOpacity>
-      )}
-
-      {profile && moderationOpts ? (
-        <HeaderReady profile={profile} moderationOpts={moderationOpts} />
-      ) : (
-        <>
-          <View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
-            <View
-              style={[
-                {width: PFP_SIZE, height: PFP_SIZE},
-                a.rounded_full,
-                t.atoms.bg_contrast_25,
-              ]}
-            />
-            <View style={a.gap_xs}>
-              <View
-                style={[
-                  {width: 120, height: 16},
-                  a.rounded_xs,
-                  t.atoms.bg_contrast_25,
-                  a.mt_xs,
-                ]}
-              />
-              <View
-                style={[
-                  {width: 175, height: 12},
-                  a.rounded_xs,
-                  t.atoms.bg_contrast_25,
-                ]}
-              />
-            </View>
-          </View>
-
-          <View style={{width: 30}} />
-        </>
-      )}
-    </View>
-  )
-}
-Header = React.memo(Header)
-
-function HeaderReady({
-  profile: profileUnshadowed,
+function InnerReady({
   moderationOpts,
+  recipient: recipientUnshadowed,
+  hasScrolled,
+  setHasScrolled,
 }: {
-  profile: AppBskyActorDefs.ProfileViewBasic
   moderationOpts: ModerationOpts
+  recipient: AppBskyActorDefs.ProfileViewBasic
+  hasScrolled: boolean
+  setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
 }) {
-  const t = useTheme()
   const convoState = useConvo()
-  const profile = useProfileShadow(profileUnshadowed)
-  const moderation = React.useMemo(
-    () => moderateProfile(profile, moderationOpts),
-    [profile, moderationOpts],
-  )
-
-  const isDeletedAccount = profile?.handle === 'missing.invalid'
-  const displayName = isDeletedAccount
-    ? 'Deleted Account'
-    : sanitizeDisplayName(
-        profile.displayName || profile.handle,
-        moderation.ui('displayName'),
-      )
+  const recipient = useProfileShadow(recipientUnshadowed)
+
+  const moderation = React.useMemo(() => {
+    return moderateProfile(recipient, moderationOpts)
+  }, [recipient, moderationOpts])
+
+  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])
 
   return (
     <>
-      <Link
-        style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
-        to={makeProfileLink(profile)}>
-        <PreviewableUserAvatar
-          size={PFP_SIZE}
-          profile={profile}
-          moderation={moderation.ui('avatar')}
-          disableHoverCard={moderation.blocked}
-        />
-        <View style={a.flex_1}>
-          <Text
-            style={[a.text_md, a.font_bold, web(a.leading_normal)]}
-            numberOfLines={1}>
-            {displayName}
-          </Text>
-          {!isDeletedAccount && (
-            <Text
-              style={[
-                t.atoms.text_contrast_medium,
-                a.text_sm,
-                web([a.leading_normal, {marginTop: -2}]),
-              ]}
-              numberOfLines={1}>
-              @{profile.handle}
-            </Text>
-          )}
-        </View>
-      </Link>
-
+      <MessagesListHeader
+        profile={recipient}
+        moderation={moderation}
+        blockInfo={blockInfo}
+      />
       {isConvoActive(convoState) && (
-        <ConvoMenu
-          convo={convoState.convo}
-          profile={profile}
-          currentScreen="conversation"
-          moderation={moderation}
+        <MessagesList
+          hasScrolled={hasScrolled}
+          setHasScrolled={setHasScrolled}
+          blocked={moderation?.blocked}
+          footer={
+            <MessagesListBlockedFooter
+              recipient={recipient}
+              convoId={convoState.convo.id}
+              hasMessages={convoState.items.length > 0}
+              blockInfo={blockInfo}
+            />
+          }
         />
       )}
     </>
diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx
index a7b7e0680..791dc82c0 100644
--- a/src/screens/Messages/List/ChatListItem.tsx
+++ b/src/screens/Messages/List/ChatListItem.tsx
@@ -65,6 +65,17 @@ function ChatListItemReady({
     [profile, moderationOpts],
   )
 
+  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'
@@ -241,7 +252,7 @@ function ChatListItemReady({
                 triggerOpacity={
                   !gtMobile || showActions || menuControl.isOpen ? 1 : 0
                 }
-                moderation={moderation}
+                blockInfo={blockInfo}
               />
             </View>
           </View>