about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-03-04 13:54:19 +0000
committerGitHub <noreply@github.com>2025-03-04 05:54:19 -0800
commitc995eb2f2fa3e73dcc6943078c85cd6a68f5370b (patch)
tree2dfea8ae6e4d86a77a90c72663b22441ca407159 /src
parent5c14f695660dcbf815a584d9d3bb037171dd0c14 (diff)
downloadvoidsky-c995eb2f2fa3e73dcc6943078c85cd6a68f5370b.tar.zst
DMs inbox (#7778)
* improve error screen

* add chat request prompt

* mock up inbox

* bigger button

* use two-button layout

* get inbox working somewhat

* fix type errors

* fetch both pages for badge

* don't include read convos in preview

* in-chat ui for non-accepted convos (part 1)

* add chatstatusinfo

* fix status info not disappearing

* get chat status info working

* change min item height

* move files around

* add updated sdk

* improve badge behaviour

* mock up mark all as read

* update sdk to 0.14.4

* hide chat status info if initiating convo

* fix unread count for deleted accounts

* add toasts after rejection

* add prompt to delete

* adjust badge on desktop

* requests -> chat requests

* fix height flicker

* add mark as read button to header

* add mark all as read APIs

* separate avatarstack into two components (#7845)

* fix messages being hidden behind chatstatusinfo

* show inbox preview on empty state

* fix empty state again

* Use new convo availability API (#7812)

* [Inbox] Accept button on convo screen (#7795)

* accept button on convo screen

* fix types

* fix type error

* improve spacing

* [DMs] Implement new log types (#7835)

* optimise badge state

* add read message log

* add isLogAcceptConvo

* mute/unmute convo logs

* use setqueriesdata

* always show label on button

* optimistically update badge

* change incorrect unread count change

* Update src/screens/Messages/Inbox.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestListItem.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix race condition with accepting convo

* fix back button on web

* filter left convos from badge

* update atproto to fix CI

* Add accept override external to convo (#7891)

* Add accept override external to convo

* rm log

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/alf/atoms.ts15
-rw-r--r--src/components/AvatarStack.tsx52
-rw-r--r--src/components/KnownFollowers.tsx29
-rw-r--r--src/components/dms/LeaveConvoPrompt.tsx6
-rw-r--r--src/components/dms/MessageProfileButton.tsx45
-rw-r--r--src/components/dms/MessagesListHeader.tsx6
-rw-r--r--src/components/dms/ReportDialog.tsx25
-rw-r--r--src/components/icons/CircleX.tsx5
-rw-r--r--src/lib/routes/types.ts3
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Messages/ChatList.tsx102
-rw-r--r--src/screens/Messages/Conversation.tsx10
-rw-r--r--src/screens/Messages/Inbox.tsx332
-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
-rw-r--r--src/screens/Settings/Settings.tsx4
-rw-r--r--src/state/messages/convo/agent.ts21
-rw-r--r--src/state/messages/convo/index.tsx4
-rw-r--r--src/state/messages/convo/types.ts8
-rw-r--r--src/state/messages/convo/util.ts18
-rw-r--r--src/state/queries/messages/accept-conversation.ts135
-rw-r--r--src/state/queries/messages/conversation.ts53
-rw-r--r--src/state/queries/messages/get-convo-availability.ts25
-rw-r--r--src/state/queries/messages/get-convo-for-members.ts35
-rw-r--r--src/state/queries/messages/leave-conversation.ts18
-rw-r--r--src/state/queries/messages/list-conversations.tsx421
-rw-r--r--src/state/queries/messages/mute-conversation.ts4
-rw-r--r--src/state/queries/messages/update-all-read.ts105
-rw-r--r--src/state/queries/profile.ts4
-rw-r--r--src/view/com/util/UserAvatar.tsx8
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx1
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx3
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx7
-rw-r--r--src/view/shell/desktop/LeftNav.tsx9
39 files changed, 1799 insertions, 340 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index baf99f110..807fd92e5 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -69,6 +69,7 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
 import HashtagScreen from '#/screens/Hashtag'
 import {MessagesScreen} from '#/screens/Messages/ChatList'
 import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
+import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
 import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
 import {ModerationScreen} from '#/screens/Moderation'
 import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
@@ -412,6 +413,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title(msg`Chat settings`), requireAuth: true}}
       />
       <Stack.Screen
+        name="MessagesInbox"
+        getComponent={() => MessagesInboxScreen}
+        options={{title: title(msg`Chat request inbox`), requireAuth: true}}
+      />
+      <Stack.Screen
         name="NotificationSettings"
         getComponent={() => NotificationSettingsScreen}
         options={{title: title(msg`Notification settings`), requireAuth: true}}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index c9db8accc..1d3d5cab3 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -31,6 +31,18 @@ export const atoms = {
     right: 0,
     bottom: 0,
   },
+  top_0: {
+    top: 0,
+  },
+  right_0: {
+    right: 0,
+  },
+  bottom_0: {
+    bottom: 0,
+  },
+  left_0: {
+    left: 0,
+  },
   z_10: {
     zIndex: 10,
   },
@@ -93,6 +105,9 @@ export const atoms = {
   /*
    * Border radius
    */
+  rounded_0: {
+    borderRadius: 0,
+  },
   rounded_2xs: {
     borderRadius: tokens.borderRadius._2xs,
   },
diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx
index 1b27a95ac..63f5ed77a 100644
--- a/src/components/AvatarStack.tsx
+++ b/src/components/AvatarStack.tsx
@@ -1,37 +1,37 @@
 import {View} from 'react-native'
 import {moderateProfile} from '@atproto/api'
 
+import {logger} from '#/logger'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useProfilesQuery} from '#/state/queries/profile'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
+import * as bsky from '#/types/bsky'
 
 export function AvatarStack({
   profiles,
   size = 26,
+  numPending,
+  backgroundColor,
 }: {
-  profiles: string[]
+  profiles: bsky.profile.AnyProfileView[]
   size?: number
+  numPending?: number
+  backgroundColor?: string
 }) {
   const halfSize = size / 2
-  const {data, error} = useProfilesQuery({handles: profiles})
   const t = useTheme()
   const moderationOpts = useModerationOpts()
 
-  if (error) {
-    console.error(error)
-    return null
-  }
-
-  const isPending = !data || !moderationOpts
+  const isPending = (numPending && profiles.length === 0) || !moderationOpts
 
   const items = isPending
-    ? Array.from({length: profiles.length}).map((_, i) => ({
+    ? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
         key: i,
         profile: null,
         moderation: null,
       }))
-    : data.profiles.map(item => ({
+    : profiles.map(item => ({
         key: item.did,
         profile: item,
         moderation: moderateProfile(item, moderationOpts),
@@ -56,7 +56,7 @@ export function AvatarStack({
               height: size,
               left: i * -halfSize,
               borderWidth: 1,
-              borderColor: t.atoms.bg.backgroundColor,
+              borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
               borderRadius: 999,
               zIndex: 3 - i,
             },
@@ -74,3 +74,33 @@ export function AvatarStack({
     </View>
   )
 }
+
+export function AvatarStackWithFetch({
+  profiles,
+  size,
+  backgroundColor,
+}: {
+  profiles: string[]
+  size?: number
+  backgroundColor?: string
+}) {
+  const {data, error} = useProfilesQuery({handles: profiles})
+
+  if (error) {
+    if (error.name !== 'AbortError') {
+      logger.error('Error fetching profiles for AvatarStack', {
+        safeMessage: error,
+      })
+    }
+    return null
+  }
+
+  return (
+    <AvatarStack
+      numPending={profiles.length}
+      profiles={data?.profiles || []}
+      size={size}
+      backgroundColor={backgroundColor}
+    />
+  )
+}
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 1e7cf448a..a883066ca 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -33,11 +33,13 @@ export function KnownFollowers({
   moderationOpts,
   onLinkPress,
   minimal,
+  showIfEmpty,
 }: {
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
   onLinkPress?: LinkProps['onPress']
   minimal?: boolean
+  showIfEmpty?: boolean
 }) {
   const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
     new Map(),
@@ -64,11 +66,12 @@ export function KnownFollowers({
         moderationOpts={moderationOpts}
         onLinkPress={onLinkPress}
         minimal={minimal}
+        showIfEmpty={showIfEmpty}
       />
     )
   }
 
-  return null
+  return <EmptyFallback show={showIfEmpty} />
 }
 
 function KnownFollowersInner({
@@ -77,22 +80,19 @@ function KnownFollowersInner({
   cachedKnownFollowers,
   onLinkPress,
   minimal,
+  showIfEmpty,
 }: {
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
   cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
   onLinkPress?: LinkProps['onPress']
   minimal?: boolean
+  showIfEmpty?: boolean
 }) {
   const t = useTheme()
   const {_} = useLingui()
 
-  const textStyle = [
-    a.flex_1,
-    a.text_sm,
-    a.leading_snug,
-    t.atoms.text_contrast_medium,
-  ]
+  const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
 
   const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
     const moderation = moderateProfile(f, moderationOpts)
@@ -115,7 +115,7 @@ function KnownFollowersInner({
    * We check above too, but here for clarity and a reminder to _check for
    * valid indices_
    */
-  if (slice.length === 0) return null
+  if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
 
   const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
 
@@ -127,7 +127,6 @@ function KnownFollowersInner({
       onPress={onLinkPress}
       to={makeProfileLink(profile, 'known-followers')}
       style={[
-        a.flex_1,
         a.flex_row,
         minimal ? a.gap_sm : a.gap_md,
         a.align_center,
@@ -243,3 +242,15 @@ function KnownFollowersInner({
     </Link>
   )
 }
+
+function EmptyFallback({show}: {show?: boolean}) {
+  const t = useTheme()
+
+  if (!show) return null
+
+  return (
+    <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
+      <Trans>Not followed by anyone you're following</Trans>
+    </Text>
+  )
+}
diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx
index c99f8d063..57acd5ca7 100644
--- a/src/components/dms/LeaveConvoPrompt.tsx
+++ b/src/components/dms/LeaveConvoPrompt.tsx
@@ -13,10 +13,12 @@ export function LeaveConvoPrompt({
   control,
   convoId,
   currentScreen,
+  hasMessages = true,
 }: {
   control: DialogOuterProps['control']
   convoId: string
   currentScreen: 'list' | 'conversation'
+  hasMessages?: boolean
 }) {
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
@@ -39,7 +41,9 @@ export function LeaveConvoPrompt({
       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.`,
+        hasMessages
+          ? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
+          : msg`Are you sure you want to leave this conversation?`,
       )}
       confirmButtonCta={_(msg`Leave`)}
       confirmButtonColor="negative"
diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx
index 5eac7f5c5..7f31f550c 100644
--- a/src/components/dms/MessageProfileButton.tsx
+++ b/src/components/dms/MessageProfileButton.tsx
@@ -8,13 +8,15 @@ import {useNavigation} from '@react-navigation/native'
 import {useEmail} from '#/lib/hooks/useEmail'
 import {NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
-import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
+import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {canBeMessaged} from '#/components/dms/util'
 import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
-import {useDialogControl} from '../Dialog'
-import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
 
 export function MessageProfileButton({
   profile,
@@ -27,10 +29,19 @@ export function MessageProfileButton({
   const {needsEmailVerification} = useEmail()
   const verifyEmailControl = useDialogControl()
 
-  const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
+  const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
+  const {mutate: initiateConvo} = useGetConvoForMembers({
+    onSuccess: ({convo}) => {
+      logEvent('chat:open', {logContext: 'ProfileHeader'})
+      navigation.navigate('MessagesConversation', {conversation: convo.id})
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to create conversation`))
+    },
+  })
 
   const onPress = React.useCallback(() => {
-    if (!convo?.id) {
+    if (!convoAvailability?.canChat) {
       return
     }
 
@@ -39,15 +50,25 @@ export function MessageProfileButton({
       return
     }
 
-    if (convo && !convo.lastMessage) {
+    if (convoAvailability.convo) {
+      logEvent('chat:open', {logContext: 'ProfileHeader'})
+      navigation.navigate('MessagesConversation', {
+        conversation: convoAvailability.convo.id,
+      })
+    } else {
       logEvent('chat:create', {logContext: 'ProfileHeader'})
+      initiateConvo([profile.did])
     }
-    logEvent('chat:open', {logContext: 'ProfileHeader'})
-
-    navigation.navigate('MessagesConversation', {conversation: convo.id})
-  }, [needsEmailVerification, verifyEmailControl, convo, navigation])
+  }, [
+    needsEmailVerification,
+    verifyEmailControl,
+    navigation,
+    profile.did,
+    initiateConvo,
+    convoAvailability,
+  ])
 
-  if (isPending) {
+  if (!convoAvailability) {
     // show pending state based on declaration
     if (canBeMessaged(profile)) {
       return (
@@ -69,7 +90,7 @@ export function MessageProfileButton({
     }
   }
 
-  if (convo) {
+  if (convoAvailability.canChat) {
     return (
       <>
         <Button
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index 7c35c30ba..8da8c015f 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -53,10 +53,10 @@ export let MessagesListHeader = ({
   }, [moderation])
 
   const onPressBack = useCallback(() => {
-    if (isWeb) {
-      navigation.replace('Messages', {})
-    } else {
+    if (navigation.canGoBack()) {
       navigation.goBack()
+    } else {
+      navigation.navigate('Messages', {})
     }
   }, [navigation])
 
diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx
index 71cca897a..c1ea854f9 100644
--- a/src/components/dms/ReportDialog.tsx
+++ b/src/components/dms/ReportDialog.tsx
@@ -311,6 +311,19 @@ function DoneStep({
     },
   })
 
+  let btnText = _(msg`Done`)
+  let toastMsg: string | undefined
+  if (actions.includes('leave') && actions.includes('block')) {
+    btnText = _(msg`Block and Delete`)
+    toastMsg = _(msg`Conversation deleted`)
+  } else if (actions.includes('leave')) {
+    btnText = _(msg`Delete Conversation`)
+    toastMsg = _(msg`Conversation deleted`)
+  } else if (actions.includes('block')) {
+    btnText = _(msg`Block User`)
+    toastMsg = _(msg`User blocked`)
+  }
+
   const onPressPrimaryAction = () => {
     control.close(() => {
       if (actions.includes('block')) {
@@ -319,18 +332,12 @@ function DoneStep({
       if (actions.includes('leave')) {
         leaveConvo()
       }
+      if (toastMsg) {
+        Toast.show(toastMsg, 'check')
+      }
     })
   }
 
-  let btnText = _(msg`Done`)
-  if (actions.includes('leave') && actions.includes('block')) {
-    btnText = _(msg`Block and Delete`)
-  } else if (actions.includes('leave')) {
-    btnText = _(msg`Delete Conversation`)
-  } else if (actions.includes('block')) {
-    btnText = _(msg`Block User`)
-  }
-
   return (
     <View style={a.gap_2xl}>
       <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
diff --git a/src/components/icons/CircleX.tsx b/src/components/icons/CircleX.tsx
new file mode 100644
index 000000000..e840bd09e
--- /dev/null
+++ b/src/components/icons/CircleX.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 51f196d09..0e38c9262 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -56,8 +56,9 @@ export type CommonNavigatorParams = {
   Search: {q?: string}
   Hashtag: {tag: string; author?: string}
   Topic: {topic: string}
-  MessagesConversation: {conversation: string; embed?: string}
+  MessagesConversation: {conversation: string; embed?: string; accept?: true}
   MessagesSettings: undefined
+  MessagesInbox: undefined
   NotificationSettings: undefined
   Feeds: undefined
   Start: {name: string; rkey: string}
diff --git a/src/routes.ts b/src/routes.ts
index 568f88bb8..b6a11acbf 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -59,6 +59,7 @@ export const router = new Router({
   // DMs
   Messages: '/messages',
   MessagesSettings: '/messages/settings',
+  MessagesInbox: '/messages/inbox',
   MessagesConversation: '/messages/:conversation',
   // starter packs
   Start: '/start/:name/:rkey',
diff --git a/src/screens/Messages/ChatList.tsx b/src/screens/Messages/ChatList.tsx
index 32b111def..b060b23e5 100644
--- a/src/screens/Messages/ChatList.tsx
+++ b/src/screens/Messages/ChatList.tsx
@@ -1,7 +1,7 @@
 import {useCallback, useEffect, useMemo, useState} from 'react'
 import {View} from 'react-native'
 import {useAnimatedRef} from 'react-native-reanimated'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useFocusEffect, useIsFocused} from '@react-navigation/native'
@@ -18,6 +18,7 @@ import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
 import {useMessagesEventBus} from '#/state/messages/events'
 import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
 import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useSession} from '#/state/session'
 import {List, ListRef} from '#/view/com/util/List'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -35,20 +36,37 @@ import {ListFooter} from '#/components/Lists'
 import {Loader} from '#/components/Loader'
 import {Text} from '#/components/Typography'
 import {ChatListItem} from './components/ChatListItem'
+import {InboxPreview} from './components/InboxPreview'
 
-type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
+type ListItem =
+  | {
+      type: 'INBOX'
+      count: number
+      profiles: ChatBskyActorDefs.ProfileViewBasic[]
+    }
+  | {
+      type: 'CONVERSATION'
+      conversation: ChatBskyConvoDefs.ConvoView
+    }
 
-function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
-  return <ChatListItem convo={item} />
+function renderItem({item}: {item: ListItem}) {
+  switch (item.type) {
+    case 'INBOX':
+      return <InboxPreview count={item.count} profiles={item.profiles} />
+    case 'CONVERSATION':
+      return <ChatListItem convo={item.conversation} />
+  }
 }
 
-function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
-  return item.id
+function keyExtractor(item: ListItem) {
+  return item.type === 'INBOX' ? 'INBOX' : item.conversation.id
 }
 
+type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
 export function MessagesScreen({navigation, route}: Props) {
   const {_} = useLingui()
   const t = useTheme()
+  const {currentAccount} = useSession()
   const newChatControl = useDialogControl()
   const scrollElRef: ListRef = useAnimatedRef()
   const pushToConversation = route.params?.pushToConversation
@@ -94,33 +112,63 @@ export function MessagesScreen({navigation, route}: Props) {
     isError,
     error,
     refetch,
-  } = useListConvosQuery()
+  } = useListConvosQuery({status: 'accepted'})
+
+  const {data: inboxData, refetch: refetchInbox} = useListConvosQuery({
+    status: 'request',
+  })
 
   useRefreshOnFocus(refetch)
+  useRefreshOnFocus(refetchInbox)
 
   const leftConvos = useLeftConvos()
 
+  const inboxPreviewConvos = useMemo(() => {
+    const inbox =
+      inboxData?.pages
+        .flatMap(page => page.convos)
+        .filter(
+          convo =>
+            !leftConvos.includes(convo.id) &&
+            !convo.muted &&
+            convo.unreadCount > 0,
+        ) ?? []
+
+    return inbox
+      .map(x => x.members.find(y => y.did !== currentAccount?.did))
+      .filter(x => !!x)
+  }, [inboxData, leftConvos, currentAccount?.did])
+
   const conversations = useMemo(() => {
     if (data?.pages) {
-      return (
-        data.pages
-          .flatMap(page => page.convos)
-          // filter out convos that are actively being left
-          .filter(convo => !leftConvos.includes(convo.id))
-      )
+      const conversations = data.pages
+        .flatMap(page => page.convos)
+        // filter out convos that are actively being left
+        .filter(convo => !leftConvos.includes(convo.id))
+
+      return [
+        {
+          type: 'INBOX',
+          count: inboxPreviewConvos.length,
+          profiles: inboxPreviewConvos.slice(0, 3),
+        },
+        ...conversations.map(
+          convo => ({type: 'CONVERSATION', conversation: convo} as const),
+        ),
+      ] satisfies ListItem[]
     }
     return []
-  }, [data, leftConvos])
+  }, [data, leftConvos, inboxPreviewConvos])
 
   const onRefresh = useCallback(async () => {
     setIsPTRing(true)
     try {
-      await refetch()
+      await Promise.all([refetch(), refetchInbox()])
     } catch (err) {
       logger.error('Failed to refresh conversations', {message: err})
     }
     setIsPTRing(false)
-  }, [refetch, setIsPTRing])
+  }, [refetch, refetchInbox, setIsPTRing])
 
   const onEndReached = useCallback(async () => {
     if (isFetchingNextPage || !hasNextPage || isError) return
@@ -157,7 +205,8 @@ export function MessagesScreen({navigation, route}: Props) {
     return listenSoftReset(onSoftReset)
   }, [onSoftReset, isScreenFocused])
 
-  if (conversations.length < 1) {
+  // Will always have 1 item - the inbox button
+  if (conversations.length < 2) {
     return (
       <Layout.Screen>
         <Header newChatControl={newChatControl} />
@@ -173,7 +222,7 @@ export function MessagesScreen({navigation, route}: Props) {
                   <View style={[a.pt_3xl, a.align_center]}>
                     <CircleInfo
                       width={48}
-                      fill={t.atoms.border_contrast_low.borderColor}
+                      fill={t.atoms.text_contrast_low.color}
                     />
                     <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
                       <Trans>Whoops!</Trans>
@@ -187,13 +236,14 @@ export function MessagesScreen({navigation, route}: Props) {
                         t.atoms.text_contrast_medium,
                         {maxWidth: 360},
                       ]}>
-                      {cleanError(error)}
+                      {cleanError(error) ||
+                        _(msg`Failed to load conversations`)}
                     </Text>
 
                     <Button
                       label={_(msg`Reload conversations`)}
-                      size="large"
-                      color="secondary"
+                      size="small"
+                      color="secondary_inverted"
                       variant="solid"
                       onPress={() => refetch()}>
                       <ButtonText>
@@ -205,6 +255,10 @@ export function MessagesScreen({navigation, route}: Props) {
                 </>
               ) : (
                 <>
+                  <InboxPreview
+                    count={inboxPreviewConvos.length}
+                    profiles={inboxPreviewConvos}
+                  />
                   <View style={[a.pt_3xl, a.align_center]}>
                     <Message width={48} fill={t.palette.primary_500} />
                     <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
@@ -253,8 +307,6 @@ export function MessagesScreen({navigation, route}: Props) {
             onRetry={fetchNextPage}
             style={{borderColor: 'transparent'}}
             hasNextPage={hasNextPage}
-            showEndMessage={true}
-            endMessageText={_(msg`No more conversations to show`)}
           />
         }
         onEndReachedThreshold={isNative ? 1.5 : 0}
@@ -290,7 +342,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
         <>
           <Layout.Header.Content>
             <Layout.Header.TitleText>
-              <Trans>Messages</Trans>
+              <Trans>Chats</Trans>
             </Layout.Header.TitleText>
           </Layout.Header.Content>
 
@@ -314,7 +366,7 @@ function Header({newChatControl}: {newChatControl: DialogControlProps}) {
           <Layout.Header.MenuButton />
           <Layout.Header.Content>
             <Layout.Header.TitleText>
-              <Trans>Messages</Trans>
+              <Trans>Chats</Trans>
             </Layout.Header.TitleText>
           </Layout.Header.Content>
           <Layout.Header.Slot>{settingsLink}</Layout.Header.Slot>
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index 69af0ea58..cac5ff157 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -7,7 +7,12 @@ import {
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {
+  RouteProp,
+  useFocusEffect,
+  useNavigation,
+  useRoute,
+} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {useEmail} from '#/lib/hooks/useEmail'
@@ -172,6 +177,8 @@ function InnerReady({
   const {_} = useLingui()
   const convoState = useConvo()
   const navigation = useNavigation<NavigationProp>()
+  const {params} =
+    useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
   const verifyEmailControl = useDialogControl()
   const {needsEmailVerification} = useEmail()
 
@@ -189,6 +196,7 @@ function InnerReady({
           hasScrolled={hasScrolled}
           setHasScrolled={setHasScrolled}
           blocked={moderation?.blocked}
+          hasAcceptOverride={!!params.accept}
           footer={
             <MessagesListBlockedFooter
               recipient={recipient}
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
new file mode 100644
index 000000000..3f3d5a8a8
--- /dev/null
+++ b/src/screens/Messages/Inbox.tsx
@@ -0,0 +1,332 @@
+import {useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {useAppState} from '#/lib/hooks/useAppState'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {
+  CommonNavigatorParams,
+  NativeStackScreenProps,
+  NavigationProp,
+} from '#/lib/routes/types'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
+import {useMessagesEventBus} from '#/state/messages/events'
+import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
+import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
+import {FAB} from '#/view/com/util/fab/FAB'
+import {List} from '#/view/com/util/List'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
+import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {RequestListItem} from './components/RequestListItem'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
+export function MessagesInboxScreen({}: Props) {
+  const {gtTablet} = useBreakpoints()
+
+  const listConvosQuery = useListConvosQuery({status: 'request'})
+  const {data} = listConvosQuery
+
+  const leftConvos = useLeftConvos()
+
+  const conversations = useMemo(() => {
+    if (data?.pages) {
+      const convos = data.pages
+        .flatMap(page => page.convos)
+        // filter out convos that are actively being left
+        .filter(convo => !leftConvos.includes(convo.id))
+
+      return convos
+    }
+    return []
+  }, [data, leftConvos])
+
+  const hasUnreadConvos = useMemo(() => {
+    return conversations.some(conversation => conversation.unreadCount > 0)
+  }, [conversations])
+
+  return (
+    <Layout.Screen testID="messagesInboxScreen">
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
+          <Layout.Header.TitleText>
+            <Trans>Chat requests</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        {hasUnreadConvos && gtTablet ? (
+          <MarkAsReadHeaderButton />
+        ) : (
+          <Layout.Header.Slot />
+        )}
+      </Layout.Header.Outer>
+      <RequestList
+        listConvosQuery={listConvosQuery}
+        conversations={conversations}
+        hasUnreadConvos={hasUnreadConvos}
+      />
+    </Layout.Screen>
+  )
+}
+
+function RequestList({
+  listConvosQuery,
+  conversations,
+  hasUnreadConvos,
+}: {
+  listConvosQuery: UseInfiniteQueryResult<
+    InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
+    Error
+  >
+  conversations: ChatBskyConvoDefs.ConvoView[]
+  hasUnreadConvos: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+
+  // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future)
+  // but only when the screen is active
+  const messagesBus = useMessagesEventBus()
+  const state = useAppState()
+  const isActive = state === 'active'
+  useFocusEffect(
+    useCallback(() => {
+      if (isActive) {
+        const unsub = messagesBus.requestPollInterval(
+          MESSAGE_SCREEN_POLL_INTERVAL,
+        )
+        return () => unsub()
+      }
+    }, [messagesBus, isActive]),
+  )
+
+  const initialNumToRender = useInitialNumToRender({minItemHeight: 130})
+  const [isPTRing, setIsPTRing] = useState(false)
+
+  const {
+    isLoading,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = listConvosQuery
+
+  useRefreshOnFocus(refetch)
+
+  const onRefresh = useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh conversations', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = useCallback(async () => {
+    if (isFetchingNextPage || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more conversations', {message: err})
+    }
+  }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
+
+  if (conversations.length < 1) {
+    return (
+      <Layout.Center>
+        {isLoading ? (
+          <View style={[a.align_center, a.pt_3xl, web({paddingTop: '10vh'})]}>
+            <Loader size="xl" />
+          </View>
+        ) : (
+          <>
+            {isError ? (
+              <>
+                <View style={[a.pt_3xl, a.align_center]}>
+                  <CircleInfoIcon
+                    width={48}
+                    fill={t.atoms.text_contrast_low.color}
+                  />
+                  <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
+                    <Trans>Whoops!</Trans>
+                  </Text>
+                  <Text
+                    style={[
+                      a.text_md,
+                      a.pb_xl,
+                      a.text_center,
+                      a.leading_snug,
+                      t.atoms.text_contrast_medium,
+                      {maxWidth: 360},
+                    ]}>
+                    {cleanError(error) || _(msg`Failed to load conversations`)}
+                  </Text>
+
+                  <Button
+                    label={_(msg`Reload conversations`)}
+                    size="small"
+                    color="secondary_inverted"
+                    variant="solid"
+                    onPress={() => refetch()}>
+                    <ButtonText>
+                      <Trans>Retry</Trans>
+                    </ButtonText>
+                    <ButtonIcon icon={RetryIcon} position="right" />
+                  </Button>
+                </View>
+              </>
+            ) : (
+              <>
+                <View style={[a.pt_3xl, a.align_center]}>
+                  <MessageIcon width={48} fill={t.palette.primary_500} />
+                  <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
+                    <Trans comment="Title message shown in chat requests inbox when it's empty">
+                      Inbox zero!
+                    </Trans>
+                  </Text>
+                  <Text
+                    style={[
+                      a.text_md,
+                      a.pb_xl,
+                      a.text_center,
+                      a.leading_snug,
+                      t.atoms.text_contrast_medium,
+                    ]}>
+                    <Trans>
+                      You don't have any chat requests at the moment.
+                    </Trans>
+                  </Text>
+                  <Button
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    label={_(msg`Go back`)}
+                    onPress={() => {
+                      if (navigation.canGoBack()) {
+                        navigation.goBack()
+                      } else {
+                        navigation.navigate('Messages', {animation: 'pop'})
+                      }
+                    }}>
+                    <ButtonIcon icon={ArrowLeftIcon} />
+                    <ButtonText>
+                      <Trans>Back to Chats</Trans>
+                    </ButtonText>
+                  </Button>
+                </View>
+              </>
+            )}
+          </>
+        )}
+      </Layout.Center>
+    )
+  }
+
+  return (
+    <>
+      <List
+        data={conversations}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
+        onEndReached={onEndReached}
+        ListFooterComponent={
+          <ListFooter
+            isFetchingNextPage={isFetchingNextPage}
+            error={cleanError(error)}
+            onRetry={fetchNextPage}
+            style={{borderColor: 'transparent'}}
+            hasNextPage={hasNextPage}
+          />
+        }
+        onEndReachedThreshold={isNative ? 1.5 : 0}
+        initialNumToRender={initialNumToRender}
+        windowSize={11}
+        desktopFixedHeight
+        sideBorders={false}
+      />
+      {hasUnreadConvos && <MarkAllReadFAB />}
+    </>
+  )
+}
+
+function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
+  return item.id
+}
+
+function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
+  return <RequestListItem convo={item} />
+}
+
+function MarkAllReadFAB() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {mutate: markAllRead} = useUpdateAllRead('request', {
+    onMutate: () => {
+      Toast.show(_(msg`Marked all as read`), 'check')
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
+    },
+  })
+
+  return (
+    <FAB
+      testID="markAllAsReadFAB"
+      onPress={() => markAllRead()}
+      icon={<CheckIcon size="lg" fill={t.palette.white} />}
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Mark all as read`)}
+      accessibilityHint=""
+    />
+  )
+}
+
+function MarkAsReadHeaderButton() {
+  const {_} = useLingui()
+  const {mutate: markAllRead} = useUpdateAllRead('request', {
+    onMutate: () => {
+      Toast.show(_(msg`Marked all as read`), 'check')
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
+    },
+  })
+
+  return (
+    <Button
+      label={_(msg`Mark all as read`)}
+      size="small"
+      color="secondary"
+      variant="solid"
+      onPress={() => markAllRead()}>
+      <ButtonIcon icon={CheckIcon} />
+      <ButtonText>
+        <Trans>Mark all as read</Trans>
+      </ButtonText>
+    </Button>
+  )
+}
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>
+  )
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index ea83b00c2..b8cdfdcb4 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -28,7 +28,7 @@ import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName'
 import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a, tokens, useTheme} from '#/alf'
-import {AvatarStack} from '#/components/AvatarStack'
+import {AvatarStackWithFetch} from '#/components/AvatarStack'
 import {useDialogControl} from '#/components/Dialog'
 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
@@ -118,7 +118,7 @@ export function SettingsScreen({}: Props) {
                 {showAccounts ? (
                   <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
                 ) : (
-                  <AvatarStack
+                  <AvatarStackWithFetch
                     profiles={accounts
                       .map(acc => acc.did)
                       .filter(did => did !== currentAccount?.did)
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index 73e75f58d..f6a8d6dc4 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -105,6 +105,7 @@ export class Convo {
     this.ingestFirehose = this.ingestFirehose.bind(this)
     this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
     this.onFirehoseError = this.onFirehoseError.bind(this)
+    this.markConvoAccepted = this.markConvoAccepted.bind(this)
   }
 
   private commit() {
@@ -145,6 +146,7 @@ export class Convo {
           deleteMessage: undefined,
           sendMessage: undefined,
           fetchMessageHistory: undefined,
+          markConvoAccepted: undefined,
         }
       }
       case ConvoStatus.Disabled:
@@ -162,6 +164,7 @@ export class Convo {
           deleteMessage: this.deleteMessage,
           sendMessage: this.sendMessage,
           fetchMessageHistory: this.fetchMessageHistory,
+          markConvoAccepted: this.markConvoAccepted,
         }
       }
       case ConvoStatus.Error: {
@@ -176,6 +179,7 @@ export class Convo {
           deleteMessage: undefined,
           sendMessage: undefined,
           fetchMessageHistory: undefined,
+          markConvoAccepted: undefined,
         }
       }
       default: {
@@ -190,6 +194,7 @@ export class Convo {
           deleteMessage: undefined,
           sendMessage: undefined,
           fetchMessageHistory: undefined,
+          markConvoAccepted: undefined,
         }
       }
     }
@@ -780,6 +785,12 @@ export class Convo {
       id: tempId,
       message,
     })
+    if (this.convo?.status === 'request') {
+      this.convo = {
+        ...this.convo,
+        status: 'accepted',
+      }
+    }
     this.commit()
 
     if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
@@ -787,6 +798,16 @@ export class Convo {
     }
   }
 
+  markConvoAccepted() {
+    if (this.convo) {
+      this.convo = {
+        ...this.convo,
+        status: 'accepted',
+      }
+    }
+    this.commit()
+  }
+
   async processPendingMessages() {
     logger.debug(
       `Convo: processing messages (${this.pendingMessages.size} remaining)`,
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index a1750bdf0..f004566e8 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -19,7 +19,7 @@ import {
   RQKEY as getConvoKey,
   useMarkAsReadMutation,
 } from '#/state/queries/messages/conversation'
-import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-conversations'
+import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
 import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
 import {useAgent} from '#/state/session'
 
@@ -104,7 +104,7 @@ export function ConvoProvider({
             })
           }
           queryClient.invalidateQueries({
-            queryKey: ListConvosQueryKey,
+            queryKey: [ListConvosQueryKeyRoot],
           })
         }
       }
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 69e15acc4..83499de2e 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -141,6 +141,7 @@ type SendMessage = (
   message: ChatBskyConvoSendMessage.InputSchema['message'],
 ) => void
 type FetchMessageHistory = () => Promise<void>
+type MarkConvoAccepted = () => void
 
 export type ConvoStateUninitialized = {
   status: ConvoStatus.Uninitialized
@@ -153,6 +154,7 @@ export type ConvoStateUninitialized = {
   deleteMessage: undefined
   sendMessage: undefined
   fetchMessageHistory: undefined
+  markConvoAccepted: undefined
 }
 export type ConvoStateInitializing = {
   status: ConvoStatus.Initializing
@@ -165,6 +167,7 @@ export type ConvoStateInitializing = {
   deleteMessage: undefined
   sendMessage: undefined
   fetchMessageHistory: undefined
+  markConvoAccepted: undefined
 }
 export type ConvoStateReady = {
   status: ConvoStatus.Ready
@@ -177,6 +180,7 @@ export type ConvoStateReady = {
   deleteMessage: DeleteMessage
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
+  markConvoAccepted: MarkConvoAccepted
 }
 export type ConvoStateBackgrounded = {
   status: ConvoStatus.Backgrounded
@@ -189,6 +193,7 @@ export type ConvoStateBackgrounded = {
   deleteMessage: DeleteMessage
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
+  markConvoAccepted: MarkConvoAccepted
 }
 export type ConvoStateSuspended = {
   status: ConvoStatus.Suspended
@@ -201,6 +206,7 @@ export type ConvoStateSuspended = {
   deleteMessage: DeleteMessage
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
+  markConvoAccepted: MarkConvoAccepted
 }
 export type ConvoStateError = {
   status: ConvoStatus.Error
@@ -213,6 +219,7 @@ export type ConvoStateError = {
   deleteMessage: undefined
   sendMessage: undefined
   fetchMessageHistory: undefined
+  markConvoAccepted: undefined
 }
 export type ConvoStateDisabled = {
   status: ConvoStatus.Disabled
@@ -225,6 +232,7 @@ export type ConvoStateDisabled = {
   deleteMessage: DeleteMessage
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
+  markConvoAccepted: MarkConvoAccepted
 }
 export type ConvoState =
   | ConvoStateUninitialized
diff --git a/src/state/messages/convo/util.ts b/src/state/messages/convo/util.ts
index 200d85dfa..92046cf1f 100644
--- a/src/state/messages/convo/util.ts
+++ b/src/state/messages/convo/util.ts
@@ -8,17 +8,21 @@ import {
 } from './types'
 
 /**
- * Checks if a `Convo` has a `status` that is "active", meaning the chat is
- * loaded and ready to be used, or its in a suspended or background state, and
- * ready for resumption.
+ * States where the convo is ready to be used - either ready, or backgrounded/suspended
+ * and ready to be resumed
  */
-export function isConvoActive(
-  convo: ConvoState,
-): convo is
+export type ActiveConvoStates =
   | ConvoStateReady
   | ConvoStateBackgrounded
   | ConvoStateSuspended
-  | ConvoStateDisabled {
+  | ConvoStateDisabled
+
+/**
+ * Checks if a `Convo` has a `status` that is "active", meaning the chat is
+ * loaded and ready to be used, or its in a suspended or background state, and
+ * ready for resumption.
+ */
+export function isConvoActive(convo: ConvoState): convo is ActiveConvoStates {
   return (
     convo.status === ConvoStatus.Ready ||
     convo.status === ConvoStatus.Backgrounded ||
diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts
new file mode 100644
index 000000000..82acb33c8
--- /dev/null
+++ b/src/state/queries/messages/accept-conversation.ts
@@ -0,0 +1,135 @@
+import {ChatBskyConvoAcceptConvo, ChatBskyConvoListConvos} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import {DM_SERVICE_HEADERS} from './const'
+import {
+  RQKEY as CONVO_LIST_KEY,
+  RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
+} from './list-conversations'
+
+export function useAcceptConversation(
+  convoId: string,
+  {
+    onSuccess,
+    onMutate,
+    onError,
+  }: {
+    onMutate?: () => void
+    onSuccess?: (data: ChatBskyConvoAcceptConvo.OutputSchema) => void
+    onError?: (error: Error) => void
+  },
+) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async () => {
+      const {data} = await agent.chat.bsky.convo.acceptConvo(
+        {convoId},
+        {headers: DM_SERVICE_HEADERS},
+      )
+
+      return data
+    },
+    onMutate: () => {
+      let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = []
+      let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = []
+      let convoBeingAccepted:
+        | ChatBskyConvoListConvos.OutputSchema['convos'][number]
+        | undefined
+      queryClient.setQueryData(
+        CONVO_LIST_KEY('request'),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          prevInboxPages = old.pages
+          return {
+            ...old,
+            pages: old.pages.map(page => {
+              const found = page.convos.find(convo => convo.id === convoId)
+              if (found) {
+                convoBeingAccepted = found
+                return {
+                  ...page,
+                  convos: page.convos.filter(convo => convo.id !== convoId),
+                }
+              }
+              return page
+            }),
+          }
+        },
+      )
+      queryClient.setQueryData(
+        CONVO_LIST_KEY('accepted'),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          prevAcceptedPages = old.pages
+          if (convoBeingAccepted) {
+            return {
+              ...old,
+              pages: [
+                {
+                  ...old.pages[0],
+                  convos: [
+                    {
+                      ...convoBeingAccepted,
+                      status: 'accepted',
+                    },
+                    ...old.pages[0].convos,
+                  ],
+                },
+                ...old.pages.slice(1),
+              ],
+            }
+          } else {
+            return old
+          }
+        },
+      )
+      onMutate?.()
+      return {prevAcceptedPages, prevInboxPages}
+    },
+    onSuccess: data => {
+      queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
+      onSuccess?.(data)
+    },
+    onError: (error, _, context) => {
+      logger.error(error)
+      queryClient.setQueryData(
+        CONVO_LIST_KEY('accepted'),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          return {
+            ...old,
+            pages: context?.prevAcceptedPages || old.pages,
+          }
+        },
+      )
+      queryClient.setQueryData(
+        CONVO_LIST_KEY('request'),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          return {
+            ...old,
+            pages: context?.prevInboxPages || old.pages,
+          }
+        },
+      )
+      queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
+      onError?.(error)
+    },
+  })
+}
diff --git a/src/state/queries/messages/conversation.ts b/src/state/queries/messages/conversation.ts
index 260524524..de5a90571 100644
--- a/src/state/queries/messages/conversation.ts
+++ b/src/state/queries/messages/conversation.ts
@@ -13,7 +13,7 @@ import {useAgent} from '#/state/session'
 import {
   ConvoListQueryData,
   getConvoFromQueryData,
-  RQKEY as LIST_CONVOS_KEY,
+  RQKEY_ROOT as LIST_CONVOS_KEY,
 } from './list-conversations'
 
 const RQKEY_ROOT = 'convo'
@@ -76,34 +76,37 @@ export function useMarkAsReadMutation() {
     onSuccess(_, {convoId}) {
       if (!convoId) return
 
-      queryClient.setQueryData(LIST_CONVOS_KEY, (old: ConvoListQueryData) => {
-        if (!old) return old
+      queryClient.setQueriesData(
+        {queryKey: [LIST_CONVOS_KEY]},
+        (old?: ConvoListQueryData) => {
+          if (!old) return old
 
-        const existingConvo = getConvoFromQueryData(convoId, old)
+          const existingConvo = getConvoFromQueryData(convoId, old)
 
-        if (existingConvo) {
-          return {
-            ...old,
-            pages: old.pages.map(page => {
-              return {
-                ...page,
-                convos: page.convos.map(convo => {
-                  if (convo.id === convoId) {
-                    return {
-                      ...convo,
-                      unreadCount: 0,
+          if (existingConvo) {
+            return {
+              ...old,
+              pages: old.pages.map(page => {
+                return {
+                  ...page,
+                  convos: page.convos.map(convo => {
+                    if (convo.id === convoId) {
+                      return {
+                        ...convo,
+                        unreadCount: 0,
+                      }
                     }
-                  }
-                  return convo
-                }),
-              }
-            }),
+                    return convo
+                  }),
+                }
+              }),
+            }
+          } else {
+            // If we somehow marked a convo as read that doesn't exist in the
+            // list, then we don't need to do anything.
           }
-        } else {
-          // If we somehow marked a convo as read that doesn't exist in the
-          // list, then we don't need to do anything.
-        }
-      })
+        },
+      )
     },
   })
 }
diff --git a/src/state/queries/messages/get-convo-availability.ts b/src/state/queries/messages/get-convo-availability.ts
new file mode 100644
index 000000000..f545c3bba
--- /dev/null
+++ b/src/state/queries/messages/get-convo-availability.ts
@@ -0,0 +1,25 @@
+import {useQuery} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
+import {useAgent} from '#/state/session'
+import {STALE} from '..'
+
+const RQKEY_ROOT = 'convo-availability'
+export const RQKEY = (did: string) => [RQKEY_ROOT, did]
+
+export function useGetConvoAvailabilityQuery(did: string) {
+  const agent = useAgent()
+
+  return useQuery({
+    queryKey: RQKEY(did),
+    queryFn: async () => {
+      const {data} = await agent.chat.bsky.convo.getConvoAvailability(
+        {members: [did]},
+        {headers: DM_SERVICE_HEADERS},
+      )
+
+      return data
+    },
+    staleTime: STALE.INFINITY,
+  })
+}
diff --git a/src/state/queries/messages/get-convo-for-members.ts b/src/state/queries/messages/get-convo-for-members.ts
index 7979e0665..3f45c2328 100644
--- a/src/state/queries/messages/get-convo-for-members.ts
+++ b/src/state/queries/messages/get-convo-for-members.ts
@@ -1,14 +1,10 @@
 import {ChatBskyConvoGetConvoForMembers} from '@atproto/api'
-import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
 
 import {logger} from '#/logger'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent} from '#/state/session'
-import {STALE} from '..'
-import {RQKEY as CONVO_KEY} from './conversation'
-
-const RQKEY_ROOT = 'convo-for-user'
-export const RQKEY = (did: string) => [RQKEY_ROOT, did]
+import {precacheConvoQuery} from './conversation'
 
 export function useGetConvoForMembers({
   onSuccess,
@@ -22,7 +18,7 @@ export function useGetConvoForMembers({
 
   return useMutation({
     mutationFn: async (members: string[]) => {
-      const {data} = await agent.api.chat.bsky.convo.getConvoForMembers(
+      const {data} = await agent.chat.bsky.convo.getConvoForMembers(
         {members: members},
         {headers: DM_SERVICE_HEADERS},
       )
@@ -30,7 +26,7 @@ export function useGetConvoForMembers({
       return data
     },
     onSuccess: data => {
-      queryClient.setQueryData(CONVO_KEY(data.convo.id), data.convo)
+      precacheConvoQuery(queryClient, data.convo)
       onSuccess?.(data)
     },
     onError: error => {
@@ -39,26 +35,3 @@ export function useGetConvoForMembers({
     },
   })
 }
-
-/**
- * Gets the conversation ID for a given DID. Returns null if it's not possible to message them.
- */
-export function useMaybeConvoForUser(did: string) {
-  const agent = useAgent()
-
-  return useQuery({
-    queryKey: RQKEY(did),
-    queryFn: async () => {
-      const convo = await agent.api.chat.bsky.convo
-        .getConvoForMembers({members: [did]}, {headers: DM_SERVICE_HEADERS})
-        .catch(() => ({success: null}))
-
-      if (convo.success) {
-        return convo.data.convo
-      } else {
-        return null
-      }
-    },
-    staleTime: STALE.INFINITY,
-  })
-}
diff --git a/src/state/queries/messages/leave-conversation.ts b/src/state/queries/messages/leave-conversation.ts
index 21cd1f18c..b17e515be 100644
--- a/src/state/queries/messages/leave-conversation.ts
+++ b/src/state/queries/messages/leave-conversation.ts
@@ -1,3 +1,4 @@
+import {useMemo} from 'react'
 import {ChatBskyConvoLeaveConvo, ChatBskyConvoListConvos} from '@atproto/api'
 import {
   useMutation,
@@ -8,7 +9,7 @@ import {
 import {logger} from '#/logger'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent} from '#/state/session'
-import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
+import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
 
 const RQKEY_ROOT = 'leave-convo'
 export function RQKEY(convoId: string | undefined) {
@@ -35,7 +36,7 @@ export function useLeaveConvo(
     mutationFn: async () => {
       if (!convoId) throw new Error('No convoId provided')
 
-      const {data} = await agent.api.chat.bsky.convo.leaveConvo(
+      const {data} = await agent.chat.bsky.convo.leaveConvo(
         {convoId},
         {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
       )
@@ -45,7 +46,7 @@ export function useLeaveConvo(
     onMutate: () => {
       let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
       queryClient.setQueryData(
-        CONVO_LIST_KEY,
+        [CONVO_LIST_KEY],
         (old?: {
           pageParams: Array<string | undefined>
           pages: Array<ChatBskyConvoListConvos.OutputSchema>
@@ -67,13 +68,13 @@ export function useLeaveConvo(
       return {prevPages}
     },
     onSuccess: data => {
-      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
       onSuccess?.(data)
     },
     onError: (error, _, context) => {
       logger.error(error)
       queryClient.setQueryData(
-        CONVO_LIST_KEY,
+        [CONVO_LIST_KEY],
         (old?: {
           pageParams: Array<string | undefined>
           pages: Array<ChatBskyConvoListConvos.OutputSchema>
@@ -85,7 +86,7 @@ export function useLeaveConvo(
           }
         },
       )
-      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY})
+      queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
       onError?.(error)
     },
   })
@@ -105,5 +106,8 @@ export function useLeftConvos() {
     filters: {mutationKey: [RQKEY_ROOT], status: 'success'},
     select: mutation => mutation.options.mutationKey?.[1] as string | undefined,
   })
-  return [...pending, ...success].filter(id => id !== undefined)
+  return useMemo(
+    () => [...pending, ...success].filter(id => id !== undefined),
+    [pending, success],
+  )
 }
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index 8c9d6c429..f5fce6347 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -9,6 +9,7 @@ import {
   ChatBskyConvoDefs,
   ChatBskyConvoListConvos,
   moderateProfile,
+  ModerationOpts,
 } from '@atproto/api'
 import {
   InfiniteData,
@@ -23,26 +24,39 @@ import {useMessagesEventBus} from '#/state/messages/events'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent, useSession} from '#/state/session'
+import {useLeftConvos} from './leave-conversation'
 
-export const RQKEY = ['convo-list']
+export const RQKEY_ROOT = 'convo-list'
+export const RQKEY = (
+  status: 'accepted' | 'request' | 'all',
+  readState: 'all' | 'unread' = 'all',
+) => [RQKEY_ROOT, status, readState]
 type RQPageParam = string | undefined
 
 export function useListConvosQuery({
   enabled,
+  status,
+  readState = 'all',
 }: {
   enabled?: boolean
+  status?: 'request' | 'accepted'
+  readState?: 'all' | 'unread'
 } = {}) {
   const agent = useAgent()
 
   return useInfiniteQuery({
     enabled,
-    queryKey: RQKEY,
+    queryKey: RQKEY(status ?? 'all', readState),
     queryFn: async ({pageParam}) => {
-      const {data} = await agent.api.chat.bsky.convo.listConvos(
-        {cursor: pageParam, limit: 20},
+      const {data} = await agent.chat.bsky.convo.listConvos(
+        {
+          limit: 20,
+          cursor: pageParam,
+          readState: readState === 'unread' ? 'unread' : undefined,
+          status,
+        },
         {headers: DM_SERVICE_HEADERS},
       )
-
       return data
     },
     initialPageParam: undefined as RQPageParam,
@@ -50,9 +64,10 @@ export function useListConvosQuery({
   })
 }
 
-const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>(
-  null,
-)
+const ListConvosContext = createContext<{
+  accepted: ChatBskyConvoDefs.ConvoView[]
+  request: ChatBskyConvoDefs.ConvoView[]
+} | null>(null)
 
 export function useListConvos() {
   const ctx = useContext(ListConvosContext)
@@ -62,12 +77,13 @@ export function useListConvos() {
   return ctx
 }
 
+const empty = {accepted: [], request: []}
 export function ListConvosProvider({children}: {children: React.ReactNode}) {
   const {hasSession} = useSession()
 
   if (!hasSession) {
     return (
-      <ListConvosContext.Provider value={[]}>
+      <ListConvosContext.Provider value={empty}>
         {children}
       </ListConvosContext.Provider>
     )
@@ -81,20 +97,23 @@ export function ListConvosProviderInner({
 }: {
   children: React.ReactNode
 }) {
-  const {refetch, data} = useListConvosQuery()
+  const {refetch, data} = useListConvosQuery({readState: 'unread'})
   const messagesBus = useMessagesEventBus()
   const queryClient = useQueryClient()
   const {currentConvoId} = useCurrentConvoId()
   const {currentAccount} = useSession()
+  const leftConvos = useLeftConvos()
 
-  const debouncedRefetch = useMemo(
-    () =>
-      throttle(refetch, 500, {
-        leading: true,
-        trailing: true,
-      }),
-    [refetch],
-  )
+  const debouncedRefetch = useMemo(() => {
+    const refetchAndInvalidate = () => {
+      refetch()
+      queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
+    }
+    return throttle(refetchAndInvalidate, 500, {
+      leading: true,
+      trailing: true,
+    })
+  }, [refetch, queryClient])
 
   useEffect(() => {
     const unsub = messagesBus.on(
@@ -105,69 +124,159 @@ export function ListConvosProviderInner({
           if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
             debouncedRefetch()
           } else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>
-              optimisticDelete(log.convoId, old),
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
             )
           } else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>
-              optimisticUpdate(log.convoId, old, convo => {
-                if (
-                  (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
-                    ChatBskyConvoDefs.isMessageView(log.message)) &&
-                  (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) ||
-                    ChatBskyConvoDefs.isMessageView(convo.lastMessage))
-                ) {
-                  return log.message.id === convo.lastMessage.id
-                    ? {
-                        ...convo,
-                        rev: log.rev,
-                        lastMessage: log.message,
-                      }
-                    : convo
-                } else {
-                  return convo
-                }
-              }),
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(log.convoId, old, convo => {
+                  if (
+                    (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
+                      ChatBskyConvoDefs.isMessageView(log.message)) &&
+                    (ChatBskyConvoDefs.isDeletedMessageView(
+                      convo.lastMessage,
+                    ) ||
+                      ChatBskyConvoDefs.isMessageView(convo.lastMessage))
+                  ) {
+                    return log.message.id === convo.lastMessage.id
+                      ? {
+                          ...convo,
+                          rev: log.rev,
+                          lastMessage: log.message,
+                        }
+                      : convo
+                  } else {
+                    return convo
+                  }
+                }),
             )
           } else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
             // Store in a new var to avoid TS errors due to closures.
             const logRef: ChatBskyConvoDefs.LogCreateMessage = log
 
-            queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
-              if (!old) return old
+            // Get all matching queries
+            const queries = queryClient.getQueriesData<ConvoListQueryData>({
+              queryKey: [RQKEY_ROOT],
+            })
 
-              function updateConvo(convo: ChatBskyConvoDefs.ConvoView) {
-                let unreadCount = convo.unreadCount
-                if (convo.id !== currentConvoId) {
-                  if (
-                    ChatBskyConvoDefs.isMessageView(logRef.message) ||
-                    ChatBskyConvoDefs.isDeletedMessageView(logRef.message)
-                  ) {
-                    if (logRef.message.sender.did !== currentAccount?.did) {
-                      unreadCount++
+            // Check if convo exists in any query
+            let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
+            for (const [_key, query] of queries) {
+              if (!query) continue
+              const convo = getConvoFromQueryData(logRef.convoId, query)
+              if (convo) {
+                foundConvo = convo
+                break
+              }
+            }
+
+            if (!foundConvo) {
+              // Convo not found, trigger refetch
+              debouncedRefetch()
+              return
+            }
+
+            // Update the convo
+            const updatedConvo = {
+              ...foundConvo,
+              rev: logRef.rev,
+              lastMessage: logRef.message,
+              unreadCount:
+                foundConvo.id !== currentConvoId
+                  ? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
+                      ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
+                    logRef.message.sender.did !== currentAccount?.did
+                    ? foundConvo.unreadCount + 1
+                    : foundConvo.unreadCount
+                  : 0,
+            }
+
+            function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) {
+              return convo.filter(c => c.id !== logRef.convoId)
+            }
+
+            // Update all matching queries
+            function updateFn(old?: ConvoListQueryData) {
+              if (!old) return old
+              return {
+                ...old,
+                pages: old.pages.map((page, i) => {
+                  if (i === 0) {
+                    return {
+                      ...page,
+                      convos: [
+                        updatedConvo,
+                        ...filterConvoFromPage(page.convos),
+                      ],
                     }
                   }
-                } else {
-                  unreadCount = 0
-                }
-
-                return {
+                  return {
+                    ...page,
+                    convos: filterConvoFromPage(page.convos),
+                  }
+                }),
+              }
+            }
+            // always update the unread one
+            queryClient.setQueriesData(
+              {queryKey: RQKEY('all', 'unread')},
+              (old?: ConvoListQueryData) =>
+                old
+                  ? updateFn(old)
+                  : ({
+                      pageParams: [undefined],
+                      pages: [{convos: [updatedConvo], cursor: undefined}],
+                    } satisfies ConvoListQueryData),
+            )
+            // update the other ones based on status of the incoming message
+            if (updatedConvo.status === 'accepted') {
+              queryClient.setQueriesData(
+                {queryKey: RQKEY('accepted')},
+                updateFn,
+              )
+            } else if (updatedConvo.status === 'request') {
+              queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
+            }
+          } else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
+            const logRef: ChatBskyConvoDefs.LogReadMessage = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
                   ...convo,
+                  unreadCount: 0,
                   rev: logRef.rev,
-                  lastMessage: logRef.message,
-                  unreadCount,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogAcceptConvo = log
+            const requests = queryClient.getQueryData<ConvoListQueryData>(
+              RQKEY('request'),
+            )
+            if (!requests) {
+              debouncedRefetch()
+              return
+            }
+            const acceptedConvo = getConvoFromQueryData(log.convoId, requests)
+            if (!acceptedConvo) {
+              debouncedRefetch()
+              return
+            }
+            queryClient.setQueryData(
+              RQKEY('request'),
+              (old?: ConvoListQueryData) =>
+                optimisticDelete(logRef.convoId, old),
+            )
+            queryClient.setQueriesData(
+              {queryKey: RQKEY('accepted')},
+              (old?: ConvoListQueryData) => {
+                if (!old) {
+                  debouncedRefetch()
+                  return old
                 }
-              }
-
-              function filterConvoFromPage(
-                convo: ChatBskyConvoDefs.ConvoView[],
-              ) {
-                return convo.filter(c => c.id !== logRef.convoId)
-              }
-
-              const existingConvo = getConvoFromQueryData(logRef.convoId, old)
-
-              if (existingConvo) {
                 return {
                   ...old,
                   pages: old.pages.map((page, i) => {
@@ -175,26 +284,38 @@ export function ListConvosProviderInner({
                       return {
                         ...page,
                         convos: [
-                          updateConvo(existingConvo),
-                          ...filterConvoFromPage(page.convos),
+                          {...acceptedConvo, status: 'accepted'},
+                          ...page.convos,
                         ],
                       }
                     }
-                    return {
-                      ...page,
-                      convos: filterConvoFromPage(page.convos),
-                    }
+                    return page
                   }),
                 }
-              } else {
-                /**
-                 * We received a message from an conversation old enough that
-                 * it doesn't exist in the query cache, meaning we need to
-                 * refetch and bump the old convo to the top.
-                 */
-                debouncedRefetch()
-              }
-            })
+              },
+            )
+          } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogMuteConvo = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  muted: true,
+                  rev: logRef.rev,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
+            const logRef: ChatBskyConvoDefs.LogUnmuteConvo = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  muted: false,
+                  rev: logRef.rev,
+                })),
+            )
           }
         }
       },
@@ -208,15 +329,21 @@ export function ListConvosProviderInner({
   }, [
     messagesBus,
     currentConvoId,
-    refetch,
     queryClient,
     currentAccount?.did,
     debouncedRefetch,
   ])
 
   const ctx = useMemo(() => {
-    return data?.pages.flatMap(page => page.convos) ?? []
-  }, [data])
+    const convos =
+      data?.pages
+        .flatMap(page => page.convos)
+        .filter(convo => !leftConvos.includes(convo.id)) ?? []
+    return {
+      accepted: convos.filter(conv => conv.status === 'accepted'),
+      request: convos.filter(conv => conv.status === 'request'),
+    }
+  }, [data, leftConvos])
 
   return (
     <ListConvosContext.Provider value={ctx}>
@@ -228,38 +355,76 @@ export function ListConvosProviderInner({
 export function useUnreadMessageCount() {
   const {currentConvoId} = useCurrentConvoId()
   const {currentAccount} = useSession()
-  const convos = useListConvos()
+  const {accepted, request} = useListConvos()
   const moderationOpts = useModerationOpts()
 
-  const count = useMemo(() => {
-    return (
-      convos
-        .filter(convo => convo.id !== currentConvoId)
-        .reduce((acc, convo) => {
-          const otherMember = convo.members.find(
-            member => member.did !== currentAccount?.did,
-          )
-
-          if (!otherMember || !moderationOpts) return acc
-
-          const moderation = moderateProfile(otherMember, moderationOpts)
-          const shouldIgnore =
-            convo.muted ||
-            moderation.blocked ||
-            otherMember.did === 'missing.invalid'
-          const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
-
-          return acc + unreadCount
-        }, 0) ?? 0
+  return useMemo<{
+    count: number
+    numUnread?: string
+    hasNew: boolean
+  }>(() => {
+    const acceptedCount = calculateCount(
+      accepted,
+      currentAccount?.did,
+      currentConvoId,
+      moderationOpts,
     )
-  }, [convos, currentAccount?.did, currentConvoId, moderationOpts])
-
-  return useMemo(() => {
-    return {
-      count,
-      numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined,
+    const requestCount = calculateCount(
+      request,
+      currentAccount?.did,
+      currentConvoId,
+      moderationOpts,
+    )
+    if (acceptedCount > 0) {
+      const total = acceptedCount + Math.min(requestCount, 1)
+      return {
+        count: total,
+        numUnread: total > 10 ? '10+' : String(total),
+        // only needed when numUnread is undefined
+        hasNew: false,
+      }
+    } else if (requestCount > 0) {
+      return {
+        count: 1,
+        numUnread: undefined,
+        hasNew: true,
+      }
+    } else {
+      return {
+        count: 0,
+        numUnread: undefined,
+        hasNew: false,
+      }
     }
-  }, [count])
+  }, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts])
+}
+
+function calculateCount(
+  convos: ChatBskyConvoDefs.ConvoView[],
+  currentAccountDid: string | undefined,
+  currentConvoId: string | undefined,
+  moderationOpts: ModerationOpts | undefined,
+) {
+  return (
+    convos
+      .filter(convo => convo.id !== currentConvoId)
+      .reduce((acc, convo) => {
+        const otherMember = convo.members.find(
+          member => member.did !== currentAccountDid,
+        )
+
+        if (!otherMember || !moderationOpts) return acc
+
+        const moderation = moderateProfile(otherMember, moderationOpts)
+        const shouldIgnore =
+          convo.muted ||
+          moderation.blocked ||
+          otherMember.handle === 'missing.invalid'
+        const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
+
+        return acc + unreadCount
+      }, 0) ?? 0
+  )
 }
 
 export type ConvoListQueryData = {
@@ -272,12 +437,16 @@ export function useOnMarkAsRead() {
 
   return useCallback(
     (chatId: string) => {
-      queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
-        return optimisticUpdate(chatId, old, convo => ({
-          ...convo,
-          unreadCount: 0,
-        }))
-      })
+      queryClient.setQueriesData(
+        {queryKey: [RQKEY_ROOT]},
+        (old?: ConvoListQueryData) => {
+          if (!old) return old
+          return optimisticUpdate(chatId, old, convo => ({
+            ...convo,
+            unreadCount: 0,
+          }))
+        },
+      )
     },
     [queryClient],
   )
@@ -285,10 +454,12 @@ export function useOnMarkAsRead() {
 
 function optimisticUpdate(
   chatId: string,
-  old: ConvoListQueryData,
-  updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
+  old?: ConvoListQueryData,
+  updateFn?: (
+    convo: ChatBskyConvoDefs.ConvoView,
+  ) => ChatBskyConvoDefs.ConvoView,
 ) {
-  if (!old) return old
+  if (!old || !updateFn) return old
 
   return {
     ...old,
@@ -301,7 +472,7 @@ function optimisticUpdate(
   }
 }
 
-function optimisticDelete(chatId: string, old: ConvoListQueryData) {
+function optimisticDelete(chatId: string, old?: ConvoListQueryData) {
   if (!old) return old
 
   return {
@@ -331,7 +502,7 @@ export function* findAllProfilesInQueryData(
   const queryDatas = queryClient.getQueriesData<
     InfiniteData<ChatBskyConvoListConvos.OutputSchema>
   >({
-    queryKey: RQKEY,
+    queryKey: [RQKEY_ROOT],
   })
   for (const [_queryKey, queryData] of queryDatas) {
     if (!queryData?.pages) {
diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts
index f32d02229..da9644145 100644
--- a/src/state/queries/messages/mute-conversation.ts
+++ b/src/state/queries/messages/mute-conversation.ts
@@ -8,7 +8,7 @@ import {InfiniteData, useMutation, useQueryClient} from '@tanstack/react-query'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 import {useAgent} from '#/state/session'
 import {RQKEY as CONVO_KEY} from './conversation'
-import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
+import {RQKEY_ROOT as CONVO_LIST_KEY} from './list-conversations'
 
 export function useMuteConvo(
   convoId: string | undefined,
@@ -53,7 +53,7 @@ export function useMuteConvo(
       )
       queryClient.setQueryData<
         InfiniteData<ChatBskyConvoListConvos.OutputSchema>
-      >(CONVO_LIST_KEY, prev => {
+      >([CONVO_LIST_KEY], prev => {
         if (!prev?.pages) return
         return {
           ...prev,
diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts
new file mode 100644
index 000000000..72fa65ee6
--- /dev/null
+++ b/src/state/queries/messages/update-all-read.ts
@@ -0,0 +1,105 @@
+import {ChatBskyConvoListConvos} from '@atproto/api'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
+import {useAgent} from '#/state/session'
+import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
+
+export function useUpdateAllRead(
+  status: 'accepted' | 'request',
+  {
+    onSuccess,
+    onMutate,
+    onError,
+  }: {
+    onMutate?: () => void
+    onSuccess?: () => void
+    onError?: (error: Error) => void
+  },
+) {
+  const queryClient = useQueryClient()
+  const agent = useAgent()
+
+  return useMutation({
+    mutationFn: async () => {
+      const {data} = await agent.chat.bsky.convo.updateAllRead(
+        {status},
+        {headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
+      )
+
+      return data
+    },
+    onMutate: () => {
+      let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
+      queryClient.setQueryData(
+        CONVO_LIST_KEY(status),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          prevPages = old.pages
+          return {
+            ...old,
+            pages: old.pages.map(page => {
+              return {
+                ...page,
+                convos: page.convos.map(convo => {
+                  return {
+                    ...convo,
+                    unreadCount: 0,
+                  }
+                }),
+              }
+            }),
+          }
+        },
+      )
+      // remove unread convos from the badge query
+      queryClient.setQueryData(
+        CONVO_LIST_KEY('all', 'unread'),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          return {
+            ...old,
+            pages: old.pages.map(page => {
+              return {
+                ...page,
+                convos: page.convos.filter(convo => convo.status !== status),
+              }
+            }),
+          }
+        },
+      )
+      onMutate?.()
+      return {prevPages}
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
+      onSuccess?.()
+    },
+    onError: (error, _, context) => {
+      logger.error(error)
+      queryClient.setQueryData(
+        CONVO_LIST_KEY(status),
+        (old?: {
+          pageParams: Array<string | undefined>
+          pages: Array<ChatBskyConvoListConvos.OutputSchema>
+        }) => {
+          if (!old) return old
+          return {
+            ...old,
+            pages: context?.prevPages || old.pages,
+          }
+        },
+      )
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
+      queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')})
+      onError?.(error)
+    },
+  })
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 2c98df634..227ca9d66 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -37,7 +37,7 @@ import {
   ProgressGuideAction,
   useProgressGuideControls,
 } from '../shell/progress-guide'
-import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-conversations'
+import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
 import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
 
@@ -456,7 +456,7 @@ export function useProfileBlockMutationQueue(
       updateProfileShadow(queryClient, did, {
         blockingUri: finalBlockingUri,
       })
-      queryClient.invalidateQueries({queryKey: RQKEY_LIST_CONVOS})
+      queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
     },
   })
 
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 386342103..e19eb06dc 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -264,13 +264,7 @@ let UserAvatar = ({
           onLoad={onLoad}
         />
       )}
-      <MediaInsetBorder
-        style={[
-          {
-            borderRadius: aviStyle.borderRadius,
-          },
-        ]}
-      />
+      <MediaInsetBorder style={[{borderRadius: aviStyle.borderRadius}]} />
       {alert}
     </View>
   ) : (
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index 7b760b069..822547d93 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -199,6 +199,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
               }
               onPress={onPressMessages}
               notificationCount={numUnreadMessages.numUnread}
+              hasNew={numUnreadMessages.hasNew}
               accessible={true}
               accessibilityRole="tab"
               accessibilityLabel={_(msg`Chat`)}
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index d80914d09..62c677ced 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -14,7 +14,6 @@ export const styles = StyleSheet.create({
     paddingRight: 10,
   },
   bottomBarWeb: {
-    // @ts-ignore web-only
     position: 'fixed',
   },
   ctrl: {
@@ -46,7 +45,7 @@ export const styles = StyleSheet.create({
   },
   hasNewBadge: {
     position: 'absolute',
-    left: '52%',
+    left: '54%',
     marginLeft: 4,
     top: 10,
     width: 8,
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index d29649c8b..8c64f81a8 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -112,11 +112,8 @@ export function BottomBarWeb() {
               <NavItem
                 routeName="Messages"
                 href="/messages"
-                notificationCount={
-                  unreadMessageCount.count > 0
-                    ? unreadMessageCount.numUnread
-                    : undefined
-                }>
+                notificationCount={unreadMessageCount.numUnread}
+                hasNew={unreadMessageCount.hasNew}>
                 {({isActive}) => {
                   const Icon = isActive ? MessageFilled : Message
                   return (
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 59055c6dc..23e1d8ea2 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -423,12 +423,12 @@ function NavItem({count, hasNew, href, icon, iconFilled, label}: NavItemProps) {
                 backgroundColor: t.palette.primary_500,
                 width: 8,
                 height: 8,
-                right: -1,
-                top: -3,
+                right: -2,
+                top: -4,
               },
               leftNavMinimal && {
-                right: 6,
-                top: 4,
+                right: 4,
+                top: 2,
               },
             ]}
           />
@@ -520,6 +520,7 @@ function ChatNavItem() {
     <NavItem
       href="/messages"
       count={numUnreadMessages.numUnread}
+      hasNew={numUnreadMessages.hasNew}
       icon={
         <Message style={pal.text} aria-hidden={true} width={NAV_ICON_WIDTH} />
       }