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/ChatListItem.tsx65
-rw-r--r--src/screens/Messages/components/ChatStatusInfo.tsx81
-rw-r--r--src/screens/Messages/components/InboxPreview.tsx73
-rw-r--r--src/screens/Messages/components/MessagesList.tsx68
-rw-r--r--src/screens/Messages/components/RequestButtons.tsx254
-rw-r--r--src/screens/Messages/components/RequestListItem.tsx78
6 files changed, 572 insertions, 47 deletions
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 501ab2374..96e010b8f 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -47,8 +47,12 @@ import * as bsky from '#/types/bsky'
 
 export let ChatListItem = ({
   convo,
+  showMenu = true,
+  children,
 }: {
   convo: ChatBskyConvoDefs.ConvoView
+  showMenu?: boolean
+  children?: React.ReactNode
 }): React.ReactNode => {
   const {currentAccount} = useSession()
   const moderationOpts = useModerationOpts()
@@ -66,7 +70,9 @@ export let ChatListItem = ({
       convo={convo}
       profile={otherUser}
       moderationOpts={moderationOpts}
-    />
+      showMenu={showMenu}>
+      {children}
+    </ChatListItemReady>
   )
 }
 
@@ -76,10 +82,14 @@ function ChatListItemReady({
   convo,
   profile: profileUnshadowed,
   moderationOpts,
+  showMenu,
+  children,
 }: {
   convo: ChatBskyConvoDefs.ConvoView
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
+  showMenu?: boolean
+  children?: React.ReactNode
 }) {
   const t = useTheme()
   const {_} = useLingui()
@@ -252,6 +262,8 @@ function ChatListItemReady({
         leftFirst: deleteAction,
       }
 
+  const hasUnread = convo.unreadCount > 0 && !isDeletedAccount
+
   return (
     <GestureActionView actions={actions}>
       <View
@@ -305,7 +317,6 @@ function ChatListItemReady({
                 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}} />
@@ -376,9 +387,7 @@ function ChatListItemReady({
                   style={[
                     a.text_sm,
                     a.leading_snug,
-                    convo.unreadCount > 0
-                      ? a.font_bold
-                      : t.atoms.text_contrast_high,
+                    hasUnread ? a.font_bold : t.atoms.text_contrast_high,
                     isDimStyle && t.atoms.text_contrast_medium,
                   ]}>
                   {lastMessage}
@@ -389,9 +398,11 @@ function ChatListItemReady({
                   size="lg"
                   style={[a.pt_xs]}
                 />
+
+                {children}
               </View>
 
-              {convo.unreadCount > 0 && (
+              {hasUnread && (
                 <View
                   style={[
                     a.absolute,
@@ -412,26 +423,28 @@ function ChatListItemReady({
           )}
         </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,
-            },
-          ]}
-          latestReportableMessage={latestReportableMessage}
-        />
+        {showMenu && (
+          <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,
+              },
+            ]}
+            latestReportableMessage={latestReportableMessage}
+          />
+        )}
         <LeaveConvoPrompt
           control={leaveConvoControl}
           convoId={convo.id}
diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx
new file mode 100644
index 000000000..a74f3092b
--- /dev/null
+++ b/src/screens/Messages/components/ChatStatusInfo.tsx
@@ -0,0 +1,81 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ActiveConvoStates} from '#/state/messages/convo'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
+import {KnownFollowers} from '#/components/KnownFollowers'
+import {usePromptControl} from '#/components/Prompt'
+import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
+
+export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const moderationOpts = useModerationOpts()
+  const {currentAccount} = useSession()
+  const leaveConvoControl = usePromptControl()
+
+  const onAcceptChat = useCallback(() => {
+    convoState.markConvoAccepted()
+  }, [convoState])
+
+  const otherUser = convoState.recipients.find(
+    user => user.did !== currentAccount?.did,
+  )
+
+  if (!moderationOpts) {
+    return null
+  }
+
+  return (
+    <View style={[t.atoms.bg, a.p_lg, a.gap_md, a.align_center]}>
+      {otherUser && (
+        <KnownFollowers
+          profile={otherUser}
+          moderationOpts={moderationOpts}
+          showIfEmpty
+        />
+      )}
+      <View style={[a.flex_row, a.gap_md, a.w_full, otherUser && a.pt_sm]}>
+        {otherUser && (
+          <RejectMenu
+            label={_(msg`Block or report`)}
+            convo={convoState.convo}
+            profile={otherUser}
+            color="negative"
+            size="small"
+            currentScreen="conversation"
+          />
+        )}
+        <DeleteChatButton
+          label={_(msg`Delete`)}
+          convo={convoState.convo}
+          color="secondary"
+          size="small"
+          currentScreen="conversation"
+          onPress={leaveConvoControl.open}
+        />
+        <LeaveConvoPrompt
+          convoId={convoState.convo.id}
+          control={leaveConvoControl}
+          currentScreen="conversation"
+          hasMessages={false}
+        />
+      </View>
+      <View style={[a.w_full, a.flex_row]}>
+        <AcceptChatButton
+          onAcceptConvo={onAcceptChat}
+          convo={convoState.convo}
+          color="primary"
+          variant="outline"
+          size="small"
+          currentScreen="conversation"
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/components/InboxPreview.tsx b/src/screens/Messages/components/InboxPreview.tsx
new file mode 100644
index 000000000..fe2803522
--- /dev/null
+++ b/src/screens/Messages/components/InboxPreview.tsx
@@ -0,0 +1,73 @@
+import {View} from 'react-native'
+import {ChatBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {AvatarStack} from '#/components/AvatarStack'
+import {ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
+import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
+import {Link} from '#/components/Link'
+
+export function InboxPreview({
+  profiles,
+}: // count,
+{
+  profiles: ChatBskyActorDefs.ProfileViewBasic[]
+  count: number
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  return (
+    <Link
+      label={_(msg`Chat request inbox`)}
+      style={[
+        a.flex_1,
+        a.px_xl,
+        a.py_sm,
+        a.flex_row,
+        a.align_center,
+        a.gap_md,
+        a.border_t,
+        {marginTop: a.border_t.borderTopWidth * -1},
+        a.border_b,
+        t.atoms.border_contrast_low,
+        {minHeight: 44},
+        a.rounded_0,
+      ]}
+      to="/messages/inbox"
+      color="secondary"
+      variant="solid">
+      <View style={[a.relative]}>
+        <ButtonIcon icon={EnvelopeIcon} size="lg" />
+        {profiles.length > 0 && (
+          <View
+            style={[
+              a.absolute,
+              a.rounded_full,
+              a.z_20,
+              {
+                top: -4,
+                right: -5,
+                width: 10,
+                height: 10,
+                backgroundColor: t.palette.primary_500,
+              },
+            ]}
+          />
+        )}
+      </View>
+      <ButtonText
+        style={[a.flex_1, a.font_bold, a.text_left]}
+        numberOfLines={1}>
+        <Trans>Chat requests</Trans>
+      </ButtonText>
+      <AvatarStack
+        profiles={profiles}
+        backgroundColor={t.atoms.bg_contrast_25.backgroundColor}
+      />
+      <ButtonIcon icon={ArrowRightIcon} size="lg" />
+    </Link>
+  )
+}
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 10a2b1d37..e84a18a6b 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -9,7 +9,6 @@ import Animated, {
   useSharedValue,
 } from 'react-native-reanimated'
 import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {
   $Typed,
   AppBskyEmbedRecord,
@@ -17,7 +16,6 @@ import {
   RichText,
 } from '@atproto/api'
 
-import {clamp} from '#/lib/numbers'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
 import {
@@ -31,6 +29,7 @@ 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 {useShellLayout} from '#/state/shell/shell-layout'
 import {
   EmojiPicker,
   EmojiPickerState,
@@ -44,6 +43,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
 import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
+import {ChatStatusInfo} from './ChatStatusInfo'
 import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
 
 function MaybeLoader({isLoading}: {isLoading: boolean}) {
@@ -85,11 +85,13 @@ export function MessagesList({
   setHasScrolled,
   blocked,
   footer,
+  hasAcceptOverride,
 }: {
   hasScrolled: boolean
   setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
   blocked?: boolean
   footer?: React.ReactNode
+  hasAcceptOverride?: boolean
 }) {
   const convoState = useConvoActive()
   const agent = useAgent()
@@ -242,8 +244,7 @@ export function MessagesList({
   )
 
   // -- Keyboard animation handling
-  const {bottom: bottomInset} = useSafeAreaInsets()
-  const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
+  const {footerHeight} = useShellLayout()
 
   const keyboardHeight = useSharedValue(0)
   const keyboardIsOpening = useSharedValue(false)
@@ -268,28 +269,30 @@ export function MessagesList({
       onMove: e => {
         'worklet'
         keyboardHeight.set(e.height)
-        if (e.height > bottomOffset) {
+        if (e.height > footerHeight.get()) {
           scrollTo(flatListRef, 0, 1e7, false)
         }
       },
       onEnd: e => {
         'worklet'
         keyboardHeight.set(e.height)
-        if (e.height > bottomOffset) {
+        if (e.height > footerHeight.get()) {
           scrollTo(flatListRef, 0, 1e7, false)
         }
         keyboardIsOpening.set(false)
       },
     },
-    [bottomOffset],
+    [footerHeight],
   )
 
   const animatedListStyle = useAnimatedStyle(() => ({
-    marginBottom: Math.max(keyboardHeight.get(), bottomOffset),
+    marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
   }))
 
   const animatedStickyViewStyle = useAnimatedStyle(() => ({
-    transform: [{translateY: -Math.max(keyboardHeight.get(), bottomOffset)}],
+    transform: [
+      {translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
+    ],
   }))
 
   // -- Message sending
@@ -437,18 +440,41 @@ export function MessagesList({
         ) : 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>
-          </>
+          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>
+                </>
+              ) : convoState.convo.status === 'request' &&
+                !hasAcceptOverride ? (
+                <ChatStatusInfo convoState={convoState} />
+              ) : (
+                <MessageInput
+                  onSendMessage={onSendMessage}
+                  hasEmbed={!!embedUri}
+                  setEmbed={setEmbed}
+                  openEmojiPicker={pos =>
+                    setEmojiPickerState({isOpen: true, pos})
+                  }>
+                  <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
+                </MessageInput>
+              )}
+            </>
+          )
         )}
       </Animated.View>
 
diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx
new file mode 100644
index 000000000..023cbff2d
--- /dev/null
+++ b/src/screens/Messages/components/RequestButtons.tsx
@@ -0,0 +1,254 @@
+import {useCallback} from 'react'
+import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {StackActions, useNavigation} from '@react-navigation/native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {NavigationProp} from '#/lib/routes/types'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {useAcceptConversation} from '#/state/queries/messages/accept-conversation'
+import {precacheConvoQuery} from '#/state/queries/messages/conversation'
+import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
+import {useProfileBlockMutationQueue} from '#/state/queries/profile'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {ReportDialog} from '#/components/dms/ReportDialog'
+import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX'
+import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
+import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+
+export function RejectMenu({
+  convo,
+  profile,
+  size = 'tiny',
+  variant = 'outline',
+  color = 'secondary',
+  label,
+  showDeleteConvo,
+  currentScreen,
+  ...props
+}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
+  label?: string
+  convo: ChatBskyConvoDefs.ConvoView
+  profile: ChatBskyActorDefs.ProfileViewBasic
+  showDeleteConvo?: boolean
+  currentScreen: 'list' | 'conversation'
+}) {
+  const {_} = useLingui()
+  const shadowedProfile = useProfileShadow(profile)
+  const navigation = useNavigation<NavigationProp>()
+  const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
+    onMutate: () => {
+      if (currentScreen === 'conversation') {
+        navigation.dispatch(StackActions.pop())
+      }
+    },
+    onError: () => {
+      Toast.show(_('Failed to delete chat'), 'xmark')
+    },
+  })
+  const [queueBlock] = useProfileBlockMutationQueue(shadowedProfile)
+
+  const onPressDelete = useCallback(() => {
+    Toast.show(_('Chat deleted'), 'check')
+    leaveConvo()
+  }, [leaveConvo, _])
+
+  const onPressBlock = useCallback(() => {
+    Toast.show(_('Account blocked'), 'check')
+    // block and also delete convo
+    queueBlock()
+    leaveConvo()
+  }, [queueBlock, leaveConvo, _])
+
+  const reportControl = useDialogControl()
+
+  const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage)
+    ? convo.lastMessage
+    : null
+
+  return (
+    <>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Reject chat request`)}>
+          {({props: triggerProps}) => (
+            <Button
+              {...triggerProps}
+              {...props}
+              label={triggerProps.accessibilityLabel}
+              style={[a.flex_1]}
+              color={color}
+              variant={variant}
+              size={size}>
+              <ButtonText>
+                {label || (
+                  <Trans comment="Reject a chat request, this opens a menu with options">
+                    Reject
+                  </Trans>
+                )}
+              </ButtonText>
+            </Button>
+          )}
+        </Menu.Trigger>
+        <Menu.Outer>
+          <Menu.Group>
+            {showDeleteConvo && (
+              <Menu.Item
+                label={_(msg`Delete conversation`)}
+                onPress={onPressDelete}>
+                <Menu.ItemText>
+                  <Trans>Delete conversation</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={CircleX_Stroke2_Corner0_Rounded} />
+              </Menu.Item>
+            )}
+            <Menu.Item label={_(msg`Block account`)} onPress={onPressBlock}>
+              <Menu.ItemText>
+                <Trans>Block account</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={PersonXIcon} />
+            </Menu.Item>
+            {/* note: last message will almost certainly be defined, since you can't
+              delete messages for other people andit's impossible for a convo on this
+              screen to have a message sent by you */}
+            {lastMessage && (
+              <Menu.Item
+                label={_(msg`Report conversation`)}
+                onPress={reportControl.open}>
+                <Menu.ItemText>
+                  <Trans>Report conversation</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={FlagIcon} />
+              </Menu.Item>
+            )}
+          </Menu.Group>
+        </Menu.Outer>
+      </Menu.Root>
+      {lastMessage && (
+        <ReportDialog
+          currentScreen={currentScreen}
+          params={{
+            type: 'convoMessage',
+            convoId: convo.id,
+            message: lastMessage,
+          }}
+          control={reportControl}
+        />
+      )}
+    </>
+  )
+}
+
+export function AcceptChatButton({
+  convo,
+  size = 'tiny',
+  variant = 'solid',
+  color = 'secondary_inverted',
+  label,
+  currentScreen,
+  onAcceptConvo,
+  ...props
+}: Omit<ButtonProps, 'onPress' | 'children' | 'label'> & {
+  label?: string
+  convo: ChatBskyConvoDefs.ConvoView
+  onAcceptConvo?: () => void
+  currentScreen: 'list' | 'conversation'
+}) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const navigation = useNavigation<NavigationProp>()
+
+  const {mutate: acceptConvo, isPending} = useAcceptConversation(convo.id, {
+    onMutate: () => {
+      onAcceptConvo?.()
+      if (currentScreen === 'list') {
+        precacheConvoQuery(queryClient, {...convo, status: 'accepted'})
+        navigation.navigate('MessagesConversation', {
+          conversation: convo.id,
+          accept: true,
+        })
+      }
+    },
+    onError: () => {
+      // Should we show a toast here? They'll be on the convo screen, and it'll make
+      // no difference if the request failed - when they send a message, the convo will be accepted
+      // automatically. The only difference is that when they back out of the convo (without sending a message), the conversation will be rejected.
+      // the list will still have this chat in it -sfn
+      Toast.show(_('Failed to accept chat'), 'xmark')
+    },
+  })
+
+  const onPressAccept = useCallback(() => {
+    acceptConvo()
+  }, [acceptConvo])
+
+  return (
+    <Button
+      {...props}
+      label={label || _(msg`Accept chat request`)}
+      size={size}
+      variant={variant}
+      color={color}
+      style={a.flex_1}
+      onPress={onPressAccept}>
+      {isPending ? (
+        <ButtonIcon icon={Loader} />
+      ) : (
+        <ButtonText>
+          {label || <Trans comment="Accept a chat request">Accept</Trans>}
+        </ButtonText>
+      )}
+    </Button>
+  )
+}
+
+export function DeleteChatButton({
+  convo,
+  size = 'tiny',
+  variant = 'outline',
+  color = 'secondary',
+  label,
+  currentScreen,
+  ...props
+}: Omit<ButtonProps, 'children' | 'label'> & {
+  label?: string
+  convo: ChatBskyConvoDefs.ConvoView
+  currentScreen: 'list' | 'conversation'
+}) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
+    onMutate: () => {
+      if (currentScreen === 'conversation') {
+        navigation.dispatch(StackActions.pop())
+      }
+    },
+    onError: () => {
+      Toast.show(_('Failed to delete chat'), 'xmark')
+    },
+  })
+
+  const onPressDelete = useCallback(() => {
+    Toast.show(_('Chat deleted'), 'check')
+    leaveConvo()
+  }, [leaveConvo, _])
+
+  return (
+    <Button
+      label={label || _(msg`Delete chat`)}
+      size={size}
+      variant={variant}
+      color={color}
+      style={a.flex_1}
+      onPress={onPressDelete}
+      {...props}>
+      <ButtonText>{label || <Trans>Delete chat</Trans>}</ButtonText>
+    </Button>
+  )
+}
diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/RequestListItem.tsx
new file mode 100644
index 000000000..654691a01
--- /dev/null
+++ b/src/screens/Messages/components/RequestListItem.tsx
@@ -0,0 +1,78 @@
+import {View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
+import {atoms as a, tokens} from '#/alf'
+import {KnownFollowers} from '#/components/KnownFollowers'
+import {Text} from '#/components/Typography'
+import {ChatListItem} from './ChatListItem'
+import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
+
+export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
+  const {currentAccount} = useSession()
+  const moderationOpts = useModerationOpts()
+
+  const otherUser = convo.members.find(
+    member => member.did !== currentAccount?.did,
+  )
+
+  if (!otherUser || !moderationOpts) {
+    return null
+  }
+
+  const isDeletedAccount = otherUser.handle === 'missing.invalid'
+
+  return (
+    <View style={[a.relative, a.flex_1]}>
+      <ChatListItem convo={convo} showMenu={false}>
+        <View style={[a.pt_xs, a.pb_2xs]}>
+          <KnownFollowers
+            profile={otherUser}
+            moderationOpts={moderationOpts}
+            minimal
+            showIfEmpty
+          />
+        </View>
+        {/* spacer, since you can't nest pressables */}
+        <View style={[a.pt_md, a.pb_xs, a.w_full, {opacity: 0}]} aria-hidden>
+          {/* Placeholder text so that it responds to the font height */}
+          <Text style={[a.text_xs, a.leading_tight, a.font_bold]}>
+            <Trans comment="Accept a chat request">Accept Request</Trans>
+          </Text>
+        </View>
+      </ChatListItem>
+      <View
+        style={[
+          a.absolute,
+          a.pr_md,
+          a.w_full,
+          a.flex_row,
+          a.align_center,
+          a.gap_sm,
+          {
+            bottom: tokens.space.md,
+            paddingLeft: tokens.space.lg + 52 + tokens.space.md,
+          },
+        ]}>
+        {!isDeletedAccount ? (
+          <>
+            <AcceptChatButton convo={convo} currentScreen="list" />
+            <RejectMenu
+              convo={convo}
+              profile={otherUser}
+              showDeleteConvo
+              currentScreen="list"
+            />
+          </>
+        ) : (
+          <>
+            <DeleteChatButton convo={convo} currentScreen="list" />
+            <View style={a.flex_1} />
+          </>
+        )}
+      </View>
+    </View>
+  )
+}