about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-05-17 14:21:15 -0700
committerGitHub <noreply@github.com>2024-05-17 16:21:15 -0500
commitd02e0884c40adebe3799254395d933205b104a86 (patch)
tree00cd164d727072ad04179662a2d7bfe914691ba9
parent1b47ea7367c7d0f37557d8f07329c3b6f97a5e03 (diff)
downloadvoidsky-d02e0884c40adebe3799254395d933205b104a86.tar.zst
[🐴] Block Info (#4068)
* get the damn thing in there 😮‍💨

* more cleanup and little fixes

another nit

nit

small annoyance

add a comment

only use `scrollTo` when necessary

remove now unnecessary styles

* move padding out

* add unblock function

* rm need for moderationpts

* ?

* ??

* extract leaveconvoprompt

* move `setHasScrolled` to `onContentSizeChanged`

* account for block footer

* wrap up

nit

make sure recipient is loaded before showing

refactor to hide chat input

typo squigglie

add report dialog

finalize delete

implement custom animation

add configurable replace animation

add leave convo to block options

* correct functionality for report

* moev component to another file

* maybe...

* fix chat item

* improve

* remove unused gtmobile

* nit

* more cleanup

* more cleanup

* fix merge

* fix header

* few more changes

* nit

* remove old
-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>