about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-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
10 files changed, 992 insertions, 75 deletions
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)