about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-05-17 17:03:50 -0500
committerGitHub <noreply@github.com>2024-05-17 15:03:50 -0700
commit1cdcb3e6c333b7ad5aa53676163643d7f43d1528 (patch)
treebd512fbaf93009bd00d60b34a7b97bb4e1469177 /src
parentd02e0884c40adebe3799254395d933205b104a86 (diff)
downloadvoidsky-1cdcb3e6c333b7ad5aa53676163643d7f43d1528.tar.zst
[🐴] New chat dialog refresh (#4071)
* Checkpoint, header styled, empty

* Checkpoint, styles

* Show recent follows in initial state, finesse some styles

* Add skeleton

* Add some limits

* Fix autofocus on web, use bottom sheet input on native

* Ignore type

* Clean up edits

* Format

* Tweak icon placement

* Fix type

* use prop for dismissing keyboard

---------

Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
-rw-r--r--src/components/Dialog/index.tsx7
-rw-r--r--src/components/Dialog/index.web.tsx21
-rw-r--r--src/components/dms/NewChat.tsx278
-rw-r--r--src/components/dms/NewChatDialog/TextInput.tsx1
-rw-r--r--src/components/dms/NewChatDialog/TextInput.web.tsx1
-rw-r--r--src/components/dms/NewChatDialog/index.tsx496
-rw-r--r--src/screens/Messages/List/index.tsx2
-rw-r--r--src/state/queries/actor-autocomplete.ts3
-rw-r--r--src/state/queries/profile-follows.ts13
9 files changed, 530 insertions, 292 deletions
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index b5258c02b..b88159613 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -1,5 +1,5 @@
 import React, {useImperativeHandle} from 'react'
-import {Dimensions, Pressable, View} from 'react-native'
+import {Dimensions, Pressable, StyleProp, View, ViewStyle} from 'react-native'
 import Animated, {useAnimatedStyle} from 'react-native-reanimated'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import BottomSheet, {
@@ -257,9 +257,10 @@ export const ScrollableInner = React.forwardRef<
 
 export const InnerFlatList = React.forwardRef<
   BottomSheetFlatListMethods,
-  BottomSheetFlatListProps<any>
+  BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
 >(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
   const insets = useSafeAreaInsets()
+
   return (
     <BottomSheetFlatList
       keyboardShouldPersistTaps="handled"
@@ -276,6 +277,8 @@ export const InnerFlatList = React.forwardRef<
         a.h_full,
         {
           marginTop: 40,
+          borderTopLeftRadius: 40,
+          borderTopRightRadius: 40,
         },
         flatten(style),
       ]}
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 4cb4e7570..35d807b4b 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -2,8 +2,10 @@ import React, {useImperativeHandle} from 'react'
 import {
   FlatList,
   FlatListProps,
+  StyleProp,
   TouchableWithoutFeedback,
   View,
+  ViewStyle,
 } from 'react-native'
 import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
@@ -199,18 +201,21 @@ export const ScrollableInner = Inner
 
 export const InnerFlatList = React.forwardRef<
   FlatList,
-  FlatListProps<any> & {label: string}
->(function InnerFlatList({label, style, ...props}, ref) {
+  FlatListProps<any> & {label: string} & {webInnerStyle?: StyleProp<ViewStyle>}
+>(function InnerFlatList({label, style, webInnerStyle, ...props}, ref) {
   const {gtMobile} = useBreakpoints()
   return (
     <Inner
       label={label}
-      // @ts-ignore web only -sfn
-      style={{
-        paddingHorizontal: 0,
-        maxHeight: 'calc(-36px + 100vh)',
-        overflow: 'hidden',
-      }}>
+      style={[
+        // @ts-ignore web only -sfn
+        {
+          paddingHorizontal: 0,
+          maxHeight: 'calc(-36px + 100vh)',
+          overflow: 'hidden',
+        },
+        webInnerStyle,
+      ]}>
       <FlatList
         ref={ref}
         style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx
deleted file mode 100644
index 3975c0c5d..000000000
--- a/src/components/dms/NewChat.tsx
+++ /dev/null
@@ -1,278 +0,0 @@
-import React, {useCallback, useMemo, useRef, useState} from 'react'
-import {Keyboard, View} from 'react-native'
-import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
-import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {sanitizeHandle} from '#/lib/strings/handles'
-import {isWeb} from '#/platform/detection'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
-import {useSession} from '#/state/session'
-import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
-import {FAB} from '#/view/com/util/fab/FAB'
-import * as Toast from '#/view/com/util/Toast'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme, web} from '#/alf'
-import * as Dialog from '#/components/Dialog'
-import * as TextField from '#/components/forms/TextField'
-import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
-import {Button} from '../Button'
-import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
-import {ListMaybePlaceholder} from '../Lists'
-import {Text} from '../Typography'
-import {canBeMessaged} from './util'
-
-export function NewChat({
-  control,
-  onNewChat,
-}: {
-  control: Dialog.DialogControlProps
-  onNewChat: (chatId: string) => void
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-
-  const {mutate: createChat} = useGetConvoForMembers({
-    onSuccess: data => {
-      onNewChat(data.convo.id)
-    },
-    onError: error => {
-      Toast.show(error.message)
-    },
-  })
-
-  const onCreateChat = useCallback(
-    (did: string) => {
-      control.close(() => createChat([did]))
-    },
-    [control, createChat],
-  )
-
-  return (
-    <>
-      <FAB
-        testID="newChatFAB"
-        onPress={control.open}
-        icon={<Plus size="lg" fill={t.palette.white} />}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`New chat`)}
-        accessibilityHint=""
-      />
-
-      <Dialog.Outer
-        control={control}
-        testID="newChatDialog"
-        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
-        <Dialog.Handle />
-        <SearchablePeopleList onCreateChat={onCreateChat} />
-      </Dialog.Outer>
-    </>
-  )
-}
-
-function SearchablePeopleList({
-  onCreateChat,
-}: {
-  onCreateChat: (did: string) => void
-}) {
-  const t = useTheme()
-  const {_} = useLingui()
-  const moderationOpts = useModerationOpts()
-  const control = Dialog.useDialogContext()
-  const listRef = useRef<BottomSheetFlatListMethods>(null)
-  const {currentAccount} = useSession()
-
-  const [searchText, setSearchText] = useState('')
-
-  const {
-    data: actorAutocompleteData,
-    isFetching,
-    isError,
-    refetch,
-  } = useActorAutocompleteQuery(searchText, true)
-
-  const renderItem = useCallback(
-    ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
-      if (!moderationOpts) return null
-
-      const moderation = moderateProfile(profile, moderationOpts)
-
-      const disabled = !canBeMessaged(profile)
-      const handle = sanitizeHandle(profile.handle, '@')
-
-      return (
-        <Button
-          label={profile.displayName || sanitizeHandle(profile.handle)}
-          onPress={() => !disabled && onCreateChat(profile.did)}>
-          {({hovered, pressed, focused}) => (
-            <View
-              style={[
-                a.flex_1,
-                a.px_md,
-                a.py_sm,
-                a.gap_md,
-                a.align_center,
-                a.flex_row,
-                a.rounded_sm,
-                disabled
-                  ? {opacity: 0.5}
-                  : pressed || focused
-                  ? t.atoms.bg_contrast_25
-                  : hovered
-                  ? t.atoms.bg_contrast_50
-                  : t.atoms.bg,
-              ]}>
-              <UserAvatar
-                size={40}
-                avatar={profile.avatar}
-                moderation={moderation.ui('avatar')}
-                type={profile.associated?.labeler ? 'labeler' : 'user'}
-              />
-              <View style={{flex: 1}}>
-                <Text
-                  style={[t.atoms.text, a.font_bold, a.leading_snug]}
-                  numberOfLines={1}>
-                  {sanitizeDisplayName(
-                    profile.displayName || sanitizeHandle(profile.handle),
-                    moderation.ui('displayName'),
-                  )}
-                </Text>
-                <Text style={t.atoms.text_contrast_high} numberOfLines={2}>
-                  {disabled ? (
-                    <Trans>{handle} can't be messaged</Trans>
-                  ) : (
-                    handle
-                  )}
-                </Text>
-              </View>
-            </View>
-          )}
-        </Button>
-      )
-    },
-    [
-      moderationOpts,
-      onCreateChat,
-      t.atoms.bg_contrast_25,
-      t.atoms.bg_contrast_50,
-      t.atoms.bg,
-      t.atoms.text,
-      t.atoms.text_contrast_high,
-    ],
-  )
-
-  const listHeader = useMemo(() => {
-    return (
-      <View style={[a.relative, a.mb_lg]}>
-        {/* cover top corners */}
-        <View
-          style={[
-            a.absolute,
-            a.inset_0,
-            {
-              borderBottomLeftRadius: 8,
-              borderBottomRightRadius: 8,
-            },
-            t.atoms.bg,
-          ]}
-        />
-        <Text
-          style={[
-            a.text_2xl,
-            a.font_bold,
-            a.leading_tight,
-            a.pb_lg,
-            web(a.pt_lg),
-          ]}>
-          <Trans>Start a new chat</Trans>
-        </Text>
-        <TextField.Root>
-          <TextField.Icon icon={Search} />
-          <Dialog.Input
-            label={_(msg`Search profiles`)}
-            placeholder={_(msg`Search`)}
-            value={searchText}
-            onChangeText={text => {
-              setSearchText(text)
-              listRef.current?.scrollToOffset({offset: 0, animated: false})
-            }}
-            returnKeyType="search"
-            clearButtonMode="while-editing"
-            maxLength={50}
-            onKeyPress={({nativeEvent}) => {
-              if (nativeEvent.key === 'Escape') {
-                control.close()
-              }
-            }}
-            autoCorrect={false}
-            autoComplete="off"
-            autoCapitalize="none"
-            autoFocus
-          />
-        </TextField.Root>
-        <Dialog.Close />
-      </View>
-    )
-  }, [t.atoms.bg, _, control, searchText])
-
-  const dataWithoutSelf = useMemo(() => {
-    return (
-      actorAutocompleteData?.filter(
-        profile => profile.did !== currentAccount?.did,
-      ) ?? []
-    )
-  }, [actorAutocompleteData, currentAccount?.did])
-
-  return (
-    <Dialog.InnerFlatList
-      ref={listRef}
-      data={dataWithoutSelf}
-      renderItem={renderItem}
-      ListHeaderComponent={
-        <>
-          {listHeader}
-          {searchText.length === 0 ? (
-            <View style={[a.pt_4xl, a.align_center, a.px_lg]}>
-              <Envelope width={64} fill={t.palette.contrast_200} />
-              <Text
-                style={[
-                  a.text_lg,
-                  a.text_center,
-                  a.mt_md,
-                  t.atoms.text_contrast_low,
-                ]}>
-                <Trans>Search for someone to start a conversation with.</Trans>
-              </Text>
-            </View>
-          ) : (
-            !actorAutocompleteData?.length && (
-              <ListMaybePlaceholder
-                isLoading={isFetching}
-                isError={isError}
-                onRetry={refetch}
-                hideBackButton={true}
-                emptyType="results"
-                sideBorders={false}
-                topBorder={false}
-                emptyMessage={
-                  isError
-                    ? _(msg`No search results found for "${searchText}".`)
-                    : _(msg`Could not load profiles. Please try again later.`)
-                }
-              />
-            )
-          )}
-        </>
-      }
-      stickyHeaderIndices={[0]}
-      keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
-      // @ts-expect-error web only
-      style={isWeb && {minHeight: '100vh'}}
-      onScrollBeginDrag={() => Keyboard.dismiss()}
-    />
-  )
-}
diff --git a/src/components/dms/NewChatDialog/TextInput.tsx b/src/components/dms/NewChatDialog/TextInput.tsx
new file mode 100644
index 000000000..b4e77e3e0
--- /dev/null
+++ b/src/components/dms/NewChatDialog/TextInput.tsx
@@ -0,0 +1 @@
+export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
diff --git a/src/components/dms/NewChatDialog/TextInput.web.tsx b/src/components/dms/NewChatDialog/TextInput.web.tsx
new file mode 100644
index 000000000..5371a534f
--- /dev/null
+++ b/src/components/dms/NewChatDialog/TextInput.web.tsx
@@ -0,0 +1 @@
+export {TextInput} from 'react-native'
diff --git a/src/components/dms/NewChatDialog/index.tsx b/src/components/dms/NewChatDialog/index.tsx
new file mode 100644
index 000000000..99572fd5c
--- /dev/null
+++ b/src/components/dms/NewChatDialog/index.tsx
@@ -0,0 +1,496 @@
+import React, {useCallback, useMemo, useRef, useState} from 'react'
+import type {TextInput as TextInputType} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
+import {useSession} from '#/state/session'
+import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
+import {FAB} from '#/view/com/util/fab/FAB'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {Button} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {TextInput} from '#/components/dms/NewChatDialog/TextInput'
+import {canBeMessaged} from '#/components/dms/util'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+type Item =
+  | {
+      type: 'profile'
+      key: string
+      enabled: boolean
+      profile: AppBskyActorDefs.ProfileView
+    }
+  | {
+      type: 'empty'
+      key: string
+      message: string
+    }
+  | {
+      type: 'placeholder'
+      key: string
+    }
+  | {
+      type: 'error'
+      key: string
+    }
+
+export function NewChat({
+  control,
+  onNewChat,
+}: {
+  control: Dialog.DialogControlProps
+  onNewChat: (chatId: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {mutate: createChat} = useGetConvoForMembers({
+    onSuccess: data => {
+      onNewChat(data.convo.id)
+    },
+    onError: error => {
+      Toast.show(error.message)
+    },
+  })
+
+  const onCreateChat = useCallback(
+    (did: string) => {
+      control.close(() => createChat([did]))
+    },
+    [control, createChat],
+  )
+
+  return (
+    <>
+      <FAB
+        testID="newChatFAB"
+        onPress={control.open}
+        icon={<Plus size="lg" fill={t.palette.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New chat`)}
+        accessibilityHint=""
+      />
+
+      <Dialog.Outer
+        control={control}
+        testID="newChatDialog"
+        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+        <SearchablePeopleList onCreateChat={onCreateChat} />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function ProfileCard({
+  enabled,
+  profile,
+  moderationOpts,
+  onPress,
+}: {
+  enabled: boolean
+  profile: AppBskyActorDefs.ProfileView
+  moderationOpts: ModerationOpts
+  onPress: (did: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const moderation = moderateProfile(profile, moderationOpts)
+  const handle = sanitizeHandle(profile.handle, '@')
+  const displayName = sanitizeDisplayName(
+    profile.displayName || sanitizeHandle(profile.handle),
+    moderation.ui('displayName'),
+  )
+
+  const handleOnPress = useCallback(() => {
+    onPress(profile.did)
+  }, [onPress, profile.did])
+
+  return (
+    <Button
+      disabled={!enabled}
+      label={_(msg`Start chat with ${displayName}`)}
+      onPress={handleOnPress}>
+      {({hovered, pressed, focused}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.py_md,
+            a.px_lg,
+            a.gap_md,
+            a.align_center,
+            a.flex_row,
+            !enabled
+              ? {opacity: 0.5}
+              : pressed || focused
+              ? t.atoms.bg_contrast_25
+              : hovered
+              ? t.atoms.bg_contrast_50
+              : t.atoms.bg,
+          ]}>
+          <UserAvatar
+            size={42}
+            avatar={profile.avatar}
+            moderation={moderation.ui('avatar')}
+            type={profile.associated?.labeler ? 'labeler' : 'user'}
+          />
+          <View style={[a.flex_1, a.gap_2xs]}>
+            <Text
+              style={[t.atoms.text, a.font_bold, a.leading_snug]}
+              numberOfLines={1}>
+              {displayName}
+            </Text>
+            <Text style={t.atoms.text_contrast_high} numberOfLines={2}>
+              {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
+            </Text>
+          </View>
+        </View>
+      )}
+    </Button>
+  )
+}
+
+function ProfileCardSkeleton() {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.py_md,
+        a.px_lg,
+        a.gap_md,
+        a.align_center,
+        a.flex_row,
+      ]}>
+      <View
+        style={[
+          a.rounded_full,
+          {width: 42, height: 42},
+          t.atoms.bg_contrast_25,
+        ]}
+      />
+
+      <View style={[a.flex_1, a.gap_sm]}>
+        <View
+          style={[
+            a.rounded_xs,
+            {width: 80, height: 14},
+            t.atoms.bg_contrast_25,
+          ]}
+        />
+        <View
+          style={[
+            a.rounded_xs,
+            {width: 120, height: 10},
+            t.atoms.bg_contrast_25,
+          ]}
+        />
+      </View>
+    </View>
+  )
+}
+
+function Empty({message}: {message: string}) {
+  const t = useTheme()
+  return (
+    <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
+      <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
+        {message}
+      </Text>
+
+      <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
+    </View>
+  )
+}
+
+function SearchInput({
+  value,
+  onChangeText,
+  onEscape,
+  inputRef,
+}: {
+  value: string
+  onChangeText: (text: string) => void
+  onEscape: () => void
+  inputRef: React.RefObject<TextInputType>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const interacted = hovered || focused
+
+  return (
+    <View
+      {...web({
+        onMouseEnter,
+        onMouseLeave,
+      })}
+      style={[a.flex_row, a.align_center, a.gap_sm]}>
+      <Search
+        size="md"
+        fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
+      />
+
+      <TextInput
+        // @ts-ignore bottom sheet input types issue — esb
+        ref={inputRef}
+        placeholder={_(msg`Search`)}
+        value={value}
+        onChangeText={onChangeText}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
+        placeholderTextColor={t.palette.contrast_500}
+        keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
+        returnKeyType="search"
+        clearButtonMode="while-editing"
+        maxLength={50}
+        onKeyPress={({nativeEvent}) => {
+          if (nativeEvent.key === 'Escape') {
+            onEscape()
+          }
+        }}
+        autoCorrect={false}
+        autoComplete="off"
+        autoCapitalize="none"
+        autoFocus
+        accessibilityLabel={_(msg`Search profiles`)}
+        accessibilityHint={_(msg`Search profiles`)}
+      />
+    </View>
+  )
+}
+
+function SearchablePeopleList({
+  onCreateChat,
+}: {
+  onCreateChat: (did: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const moderationOpts = useModerationOpts()
+  const control = Dialog.useDialogContext()
+  const listRef = useRef<BottomSheetFlatListMethods>(null)
+  const {currentAccount} = useSession()
+  const inputRef = React.useRef<TextInputType>(null)
+
+  const [searchText, setSearchText] = useState('')
+
+  const {
+    data: results,
+    isError,
+    isFetching,
+  } = useActorAutocompleteQuery(searchText, true, 12)
+  const {data: follows} = useProfileFollowsQuery(currentAccount?.did, {
+    limit: 12,
+  })
+
+  const items = React.useMemo(() => {
+    let _items: Item[] = []
+
+    if (isError) {
+      _items.push({
+        type: 'empty',
+        key: 'empty',
+        message: _(msg`We're having network issues, try again`),
+      })
+    } else if (searchText.length) {
+      if (results?.length) {
+        for (const profile of results) {
+          if (profile.did === currentAccount?.did) continue
+          _items.push({
+            type: 'profile',
+            key: profile.did,
+            enabled: canBeMessaged(profile),
+            profile,
+          })
+        }
+
+        _items = _items.sort(a => {
+          // @ts-ignore
+          return a.enabled ? -1 : 1
+        })
+      }
+    } else {
+      if (follows) {
+        for (const page of follows.pages) {
+          for (const profile of page.follows) {
+            _items.push({
+              type: 'profile',
+              key: profile.did,
+              enabled: canBeMessaged(profile),
+              profile,
+            })
+          }
+        }
+
+        _items = _items.sort(a => {
+          // @ts-ignore
+          return a.enabled ? -1 : 1
+        })
+      } else {
+        Array(10)
+          .fill(0)
+          .forEach((_, i) => {
+            _items.push({
+              type: 'placeholder',
+              key: i + '',
+            })
+          })
+      }
+    }
+
+    return _items
+  }, [_, searchText, results, isError, currentAccount?.did, follows])
+
+  if (searchText && !isFetching && !items.length && !isError) {
+    items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
+  }
+
+  const renderItems = React.useCallback(
+    ({item}: {item: Item}) => {
+      switch (item.type) {
+        case 'profile': {
+          return (
+            <ProfileCard
+              key={item.key}
+              enabled={item.enabled}
+              profile={item.profile}
+              moderationOpts={moderationOpts!}
+              onPress={onCreateChat}
+            />
+          )
+        }
+        case 'placeholder': {
+          return <ProfileCardSkeleton key={item.key} />
+        }
+        case 'empty': {
+          return <Empty key={item.key} message={item.message} />
+        }
+        default:
+          return null
+      }
+    },
+    [moderationOpts, onCreateChat],
+  )
+
+  React.useLayoutEffect(() => {
+    if (isWeb) {
+      setImmediate(() => {
+        inputRef?.current?.focus()
+      })
+    }
+  }, [])
+
+  const listHeader = useMemo(() => {
+    return (
+      <View
+        style={[
+          a.relative,
+          a.pt_md,
+          a.pb_xs,
+          a.px_lg,
+          a.border_b,
+          t.atoms.border_contrast_low,
+          t.atoms.bg,
+          native([a.pt_lg]),
+        ]}>
+        <View
+          style={[
+            a.relative,
+            native(a.align_center),
+            a.justify_center,
+            {height: 32},
+          ]}>
+          <Button
+            label={_(msg`Close`)}
+            size="small"
+            shape="round"
+            variant="ghost"
+            color="secondary"
+            style={[
+              a.absolute,
+              a.z_20,
+              native({
+                left: -7,
+              }),
+              web({
+                right: -4,
+              }),
+            ]}
+            onPress={() => control.close()}>
+            {isWeb ? (
+              <X size="md" fill={t.palette.contrast_500} />
+            ) : (
+              <ChevronLeft size="md" fill={t.palette.contrast_500} />
+            )}
+          </Button>
+          <Text
+            style={[
+              a.z_10,
+              a.text_lg,
+              a.font_bold,
+              a.leading_tight,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Start a new chat</Trans>
+          </Text>
+        </View>
+
+        <View style={[native([a.pt_sm]), web([a.pt_xs])]}>
+          <SearchInput
+            inputRef={inputRef}
+            value={searchText}
+            onChangeText={text => {
+              setSearchText(text)
+              listRef.current?.scrollToOffset({offset: 0, animated: false})
+            }}
+            onEscape={control.close}
+          />
+        </View>
+      </View>
+    )
+  }, [t, _, control, searchText])
+
+  return (
+    <Dialog.InnerFlatList
+      ref={listRef}
+      data={items}
+      renderItem={renderItems}
+      ListHeaderComponent={listHeader}
+      stickyHeaderIndices={[0]}
+      keyExtractor={(item: Item) => item.key}
+      style={[
+        web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
+        native({
+          paddingHorizontal: 0,
+          marginTop: 0,
+          paddingTop: 0,
+        }),
+      ]}
+      webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+      keyboardDismissMode="on-drag"
+    />
+  )
+}
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index e36d1edf2..c198d44c4 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -18,7 +18,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {DialogControlProps, useDialogControl} from '#/components/Dialog'
 import {MessagesNUX} from '#/components/dms/MessagesNUX'
-import {NewChat} from '#/components/dms/NewChat'
+import {NewChat} from '#/components/dms/NewChatDialog'
 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 8708a244b..17b00dc26 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -20,6 +20,7 @@ export const RQKEY = (prefix: string) => [RQKEY_ROOT, prefix]
 export function useActorAutocompleteQuery(
   prefix: string,
   maintainData?: boolean,
+  limit?: number,
 ) {
   const moderationOpts = useModerationOpts()
   const {getAgent} = useAgent()
@@ -37,7 +38,7 @@ export function useActorAutocompleteQuery(
       const res = prefix
         ? await getAgent().searchActorsTypeahead({
             q: prefix,
-            limit: 8,
+            limit: limit || 8,
           })
         : undefined
       return res?.data.actors || []
diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts
index 23c0dce3e..1919409c7 100644
--- a/src/state/queries/profile-follows.ts
+++ b/src/state/queries/profile-follows.ts
@@ -16,7 +16,16 @@ type RQPageParam = string | undefined
 const RQKEY_ROOT = 'profile-follows'
 export const RQKEY = (did: string) => [RQKEY_ROOT, did]
 
-export function useProfileFollowsQuery(did: string | undefined) {
+export function useProfileFollowsQuery(
+  did: string | undefined,
+  {
+    limit,
+  }: {
+    limit?: number
+  } = {
+    limit: PAGE_SIZE,
+  },
+) {
   const {getAgent} = useAgent()
   return useInfiniteQuery<
     AppBskyGraphGetFollows.OutputSchema,
@@ -30,7 +39,7 @@ export function useProfileFollowsQuery(did: string | undefined) {
     async queryFn({pageParam}: {pageParam: RQPageParam}) {
       const res = await getAgent().app.bsky.graph.getFollows({
         actor: did || '',
-        limit: PAGE_SIZE,
+        limit: limit || PAGE_SIZE,
         cursor: pageParam,
       })
       return res.data