about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/ProfileCard.tsx48
-rw-r--r--src/components/dialogs/SearchablePeopleList.tsx (renamed from src/components/dms/dialogs/SearchablePeopleList.tsx)194
-rw-r--r--src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx180
-rw-r--r--src/components/dms/dialogs/NewChatDialog.tsx3
-rw-r--r--src/components/dms/dialogs/ShareViaChatDialog.tsx3
-rw-r--r--src/state/modals/index.tsx14
-rw-r--r--src/state/queries/list-memberships.ts24
-rw-r--r--src/view/com/modals/ListAddRemoveUsers.tsx316
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx5
-rw-r--r--src/view/screens/ProfileList.tsx52
11 files changed, 378 insertions, 465 deletions
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 394ff9946..1a64c51d5 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -166,29 +166,47 @@ export function NameAndHandle({
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
 }) {
-  const t = useTheme()
+  return (
+    <View style={[a.flex_1]}>
+      <Name profile={profile} moderationOpts={moderationOpts} />
+      <Handle profile={profile} />
+    </View>
+  )
+}
+
+export function Name({
+  profile,
+  moderationOpts,
+}: {
+  profile: bsky.profile.AnyProfileView
+  moderationOpts: ModerationOpts
+}) {
   const moderation = moderateProfile(profile, moderationOpts)
   const name = sanitizeDisplayName(
     profile.displayName || sanitizeHandle(profile.handle),
     moderation.ui('displayName'),
   )
+  return (
+    <Text
+      emoji
+      style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
+      numberOfLines={1}>
+      {name}
+    </Text>
+  )
+}
+
+export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
+  const t = useTheme()
   const handle = sanitizeHandle(profile.handle, '@')
 
   return (
-    <View style={[a.flex_1]}>
-      <Text
-        emoji
-        style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
-        numberOfLines={1}>
-        {name}
-      </Text>
-      <Text
-        emoji
-        style={[a.leading_snug, t.atoms.text_contrast_medium]}
-        numberOfLines={1}>
-        {handle}
-      </Text>
-    </View>
+    <Text
+      emoji
+      style={[a.leading_snug, t.atoms.text_contrast_medium]}
+      numberOfLines={1}>
+      {handle}
+    </Text>
   )
 }
 
diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx
index 05d6f723e..26e20db57 100644
--- a/src/components/dms/dialogs/SearchablePeopleList.tsx
+++ b/src/components/dialogs/SearchablePeopleList.tsx
@@ -1,4 +1,5 @@
-import React, {
+import {
+  Fragment,
   useCallback,
   useLayoutEffect,
   useMemo,
@@ -6,7 +7,7 @@ import React, {
   useState,
 } from 'react'
 import {TextInput, View} from 'react-native'
-import {moderateProfile, ModerationOpts} from '@atproto/api'
+import {moderateProfile, type ModerationOpts} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -18,48 +19,62 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
 import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
 import {useSession} from '#/state/session'
-import {ListMethods} from '#/view/com/util/List'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, native, useTheme, web} from '#/alf'
+import {type ListMethods} from '#/view/com/util/List'
+import {android, atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {canBeMessaged} from '#/components/dms/util'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
-import * as bsky from '#/types/bsky'
+import type * as bsky from '#/types/bsky'
 
-type Item =
-  | {
-      type: 'profile'
-      key: string
-      enabled: boolean
-      profile: bsky.profile.AnyProfileView
-    }
-  | {
-      type: 'empty'
-      key: string
-      message: string
-    }
-  | {
-      type: 'placeholder'
-      key: string
-    }
-  | {
-      type: 'error'
-      key: string
-    }
+export type ProfileItem = {
+  type: 'profile'
+  key: string
+  profile: bsky.profile.AnyProfileView
+}
+
+type EmptyItem = {
+  type: 'empty'
+  key: string
+  message: string
+}
+
+type PlaceholderItem = {
+  type: 'placeholder'
+  key: string
+}
+
+type ErrorItem = {
+  type: 'error'
+  key: string
+}
+
+type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
 
 export function SearchablePeopleList({
   title,
-  onSelectChat,
   showRecentConvos,
+  sortByMessageDeclaration,
+  onSelectChat,
+  renderProfileCard,
 }: {
   title: string
-  onSelectChat: (did: string) => void
   showRecentConvos?: boolean
-}) {
+  sortByMessageDeclaration?: boolean
+} & (
+  | {
+      renderProfileCard: (item: ProfileItem) => React.ReactNode
+      onSelectChat?: undefined
+    }
+  | {
+      onSelectChat: (did: string) => void
+      renderProfileCard?: undefined
+    }
+)) {
   const t = useTheme()
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
@@ -98,15 +113,17 @@ export function SearchablePeopleList({
           _items.push({
             type: 'profile',
             key: profile.did,
-            enabled: canBeMessaged(profile),
             profile,
           })
         }
 
-        _items = _items.sort(item => {
-          // @ts-ignore
-          return item.enabled ? -1 : 1
-        })
+        if (sortByMessageDeclaration) {
+          _items = _items.sort(item => {
+            return item.type === 'profile' && canBeMessaged(item.profile)
+              ? -1
+              : 1
+          })
+        }
       }
     } else {
       const placeholders: Item[] = Array(10)
@@ -134,14 +151,13 @@ export function SearchablePeopleList({
                 _items.push({
                   type: 'profile',
                   key: profile.did,
-                  enabled: true,
                   profile,
                 })
               }
             }
           }
 
-          let followsItems: typeof _items = []
+          let followsItems: ProfileItem[] = []
 
           for (const page of follows.pages) {
             for (const profile of page.follows) {
@@ -150,17 +166,17 @@ export function SearchablePeopleList({
               followsItems.push({
                 type: 'profile',
                 key: profile.did,
-                enabled: canBeMessaged(profile),
                 profile,
               })
             }
           }
 
-          // only sort follows
-          followsItems = followsItems.sort(item => {
-            // @ts-ignore
-            return item.enabled ? -1 : 1
-          })
+          if (sortByMessageDeclaration) {
+            // only sort follows
+            followsItems = followsItems.sort(item => {
+              return canBeMessaged(item.profile) ? -1 : 1
+            })
+          }
 
           // then append
           _items.push(...followsItems)
@@ -173,16 +189,18 @@ export function SearchablePeopleList({
             _items.push({
               type: 'profile',
               key: profile.did,
-              enabled: canBeMessaged(profile),
               profile,
             })
           }
         }
 
-        _items = _items.sort(item => {
-          // @ts-ignore
-          return item.enabled ? -1 : 1
-        })
+        if (sortByMessageDeclaration) {
+          _items = _items.sort(item => {
+            return item.type === 'profile' && canBeMessaged(item.profile)
+              ? -1
+              : 1
+          })
+        }
       } else {
         _items.push(...placeholders)
       }
@@ -198,6 +216,7 @@ export function SearchablePeopleList({
     follows,
     convos,
     showRecentConvos,
+    sortByMessageDeclaration,
   ])
 
   if (searchText && !isFetching && !items.length && !isError) {
@@ -208,15 +227,18 @@ export function SearchablePeopleList({
     ({item}: {item: Item}) => {
       switch (item.type) {
         case 'profile': {
-          return (
-            <ProfileCard
-              key={item.key}
-              enabled={item.enabled}
-              profile={item.profile}
-              moderationOpts={moderationOpts!}
-              onPress={onSelectChat}
-            />
-          )
+          if (renderProfileCard) {
+            return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
+          } else {
+            return (
+              <DefaultProfileCard
+                key={item.key}
+                profile={item.profile}
+                moderationOpts={moderationOpts!}
+                onPress={onSelectChat}
+              />
+            )
+          }
         }
         case 'placeholder': {
           return <ProfileCardSkeleton key={item.key} />
@@ -228,7 +250,7 @@ export function SearchablePeopleList({
           return null
       }
     },
-    [moderationOpts, onSelectChat],
+    [moderationOpts, onSelectChat, renderProfileCard],
   )
 
   useLayoutEffect(() => {
@@ -247,6 +269,10 @@ export function SearchablePeopleList({
           a.relative,
           web(a.pt_lg),
           native(a.pt_4xl),
+          android({
+            borderTopLeftRadius: a.rounded_md.borderRadius,
+            borderTopRightRadius: a.rounded_md.borderRadius,
+          }),
           a.pb_xs,
           a.px_lg,
           a.border_b,
@@ -327,19 +353,18 @@ export function SearchablePeopleList({
   )
 }
 
-function ProfileCard({
-  enabled,
+function DefaultProfileCard({
   profile,
   moderationOpts,
   onPress,
 }: {
-  enabled: boolean
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
   onPress: (did: string) => void
 }) {
   const t = useTheme()
   const {_} = useLingui()
+  const enabled = canBeMessaged(profile)
   const moderation = moderateProfile(profile, moderationOpts)
   const handle = sanitizeHandle(profile.handle, '@')
   const displayName = sanitizeDisplayName(
@@ -360,38 +385,35 @@ function ProfileCard({
         <View
           style={[
             a.flex_1,
-            a.py_md,
+            a.py_sm,
             a.px_lg,
-            a.gap_md,
-            a.align_center,
-            a.flex_row,
             !enabled
               ? {opacity: 0.5}
-              : pressed || focused
+              : pressed || focused || hovered
               ? 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_tight, a.self_start]}
-              numberOfLines={1}
-              emoji>
-              {displayName}
-            </Text>
-            <Text
-              style={[a.leading_tight, t.atoms.text_contrast_high]}
-              numberOfLines={2}>
-              {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
-            </Text>
-          </View>
+          <ProfileCard.Header>
+            <ProfileCard.Avatar
+              profile={profile}
+              moderationOpts={moderationOpts}
+            />
+            <View style={[a.flex_1]}>
+              <ProfileCard.Name
+                profile={profile}
+                moderationOpts={moderationOpts}
+              />
+              {enabled ? (
+                <ProfileCard.Handle profile={profile} />
+              ) : (
+                <Text
+                  style={[a.leading_snug, t.atoms.text_contrast_high]}
+                  numberOfLines={2}>
+                  <Trans>{handle} can't be messaged</Trans>
+                </Text>
+              )}
+            </View>
+          </ProfileCard.Header>
         </View>
       )}
     </Button>
diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx
new file mode 100644
index 000000000..d975c89ed
--- /dev/null
+++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx
@@ -0,0 +1,180 @@
+import {useCallback, useMemo} from 'react'
+import {View} from 'react-native'
+import {type AppBskyGraphDefs, type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {cleanError} from '#/lib/strings/errors'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {
+  getMembership,
+  type ListMembersip,
+  useDangerousListMembershipsQuery,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {
+  type ProfileItem,
+  SearchablePeopleList,
+} from '#/components/dialogs/SearchablePeopleList'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import type * as bsky from '#/types/bsky'
+
+export function ListAddRemoveUsersDialog({
+  control,
+  list,
+  onChange,
+}: {
+  control: Dialog.DialogControlProps
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: bsky.profile.AnyProfileView,
+  ) => void | undefined
+}) {
+  return (
+    <Dialog.Outer control={control} testID="listAddRemoveUsersDialog">
+      <Dialog.Handle />
+      <DialogInner list={list} onChange={onChange} />
+    </Dialog.Outer>
+  )
+}
+
+function DialogInner({
+  list,
+  onChange,
+}: {
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: bsky.profile.AnyProfileView,
+  ) => void | undefined
+}) {
+  const {_} = useLingui()
+  const moderationOpts = useModerationOpts()
+  const {data: memberships} = useDangerousListMembershipsQuery()
+
+  const renderProfileCard = useCallback(
+    (item: ProfileItem) => {
+      return (
+        <UserResult
+          profile={item.profile}
+          onChange={onChange}
+          memberships={memberships}
+          list={list}
+          moderationOpts={moderationOpts}
+        />
+      )
+    },
+    [onChange, memberships, list, moderationOpts],
+  )
+
+  return (
+    <SearchablePeopleList
+      title={_(msg`Add people to list`)}
+      renderProfileCard={renderProfileCard}
+    />
+  )
+}
+
+function UserResult({
+  profile,
+  list,
+  memberships,
+  onChange,
+  moderationOpts,
+}: {
+  profile: bsky.profile.AnyProfileView
+  list: AppBskyGraphDefs.ListView
+  memberships: ListMembersip[] | undefined
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: bsky.profile.AnyProfileView,
+  ) => void | undefined
+  moderationOpts?: ModerationOpts
+}) {
+  const {_} = useLingui()
+  const membership = useMemo(
+    () => getMembership(memberships, list.uri, profile.did),
+    [memberships, list.uri, profile.did],
+  )
+  const {mutate: listMembershipAdd, isPending: isAddingPending} =
+    useListMembershipAddMutation({
+      onSuccess: () => {
+        Toast.show(_(msg`Added to list`))
+        onChange?.('add', profile)
+      },
+      onError: e => Toast.show(cleanError(e), 'xmark'),
+    })
+  const {mutate: listMembershipRemove, isPending: isRemovingPending} =
+    useListMembershipRemoveMutation({
+      onSuccess: () => {
+        Toast.show(_(msg`Removed from list`))
+        onChange?.('remove', profile)
+      },
+      onError: e => Toast.show(cleanError(e), 'xmark'),
+    })
+  const isMutating = isAddingPending || isRemovingPending
+
+  const onToggleMembership = useCallback(() => {
+    if (typeof membership === 'undefined') {
+      return
+    }
+    if (membership === false) {
+      listMembershipAdd({
+        listUri: list.uri,
+        actorDid: profile.did,
+      })
+    } else {
+      listMembershipRemove({
+        listUri: list.uri,
+        actorDid: profile.did,
+        membershipUri: membership,
+      })
+    }
+  }, [list, profile, membership, listMembershipAdd, listMembershipRemove])
+
+  if (!moderationOpts) return null
+
+  return (
+    <View style={[a.flex_1, a.py_sm, a.px_lg]}>
+      <ProfileCard.Header>
+        <ProfileCard.Avatar profile={profile} moderationOpts={moderationOpts} />
+        <View style={[a.flex_1]}>
+          <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
+          <ProfileCard.Handle profile={profile} />
+        </View>
+        {membership !== undefined && (
+          <Button
+            label={
+              membership === false
+                ? _(msg`Add user to list`)
+                : _(msg`Remove user from list`)
+            }
+            onPress={onToggleMembership}
+            disabled={isMutating}
+            size="small"
+            variant="solid"
+            color="secondary">
+            {isMutating ? (
+              <ButtonIcon icon={Loader} />
+            ) : (
+              <ButtonText>
+                {membership === false ? (
+                  <Trans>Add</Trans>
+                ) : (
+                  <Trans>Remove</Trans>
+                )}
+              </ButtonText>
+            )}
+          </Button>
+        )}
+      </ProfileCard.Header>
+    </View>
+  )
+}
diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx
index c7fedb488..a5ba793fb 100644
--- a/src/components/dms/dialogs/NewChatDialog.tsx
+++ b/src/components/dms/dialogs/NewChatDialog.tsx
@@ -11,9 +11,9 @@ import * as Toast from '#/view/com/util/Toast'
 import {useTheme} from '#/alf'
 import * as Dialog from '#/components/Dialog'
 import {useDialogControl} from '#/components/Dialog'
+import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
-import {SearchablePeopleList} from './SearchablePeopleList'
 
 export function NewChat({
   control,
@@ -71,6 +71,7 @@ export function NewChat({
         <SearchablePeopleList
           title={_(msg`Start a new chat`)}
           onSelectChat={onCreateChat}
+          sortByMessageDeclaration
         />
       </Dialog.Outer>
 
diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx
index 4bb27ae69..97897bc28 100644
--- a/src/components/dms/dialogs/ShareViaChatDialog.tsx
+++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx
@@ -7,7 +7,7 @@ import {logger} from '#/logger'
 import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
 import * as Toast from '#/view/com/util/Toast'
 import * as Dialog from '#/components/Dialog'
-import {SearchablePeopleList} from './SearchablePeopleList'
+import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList'
 
 export function SendViaChatDialog({
   control,
@@ -62,6 +62,7 @@ function SendViaChatDialogInner({
       title={_(msg`Send post to...`)}
       onSelectChat={onCreateChat}
       showRecentConvos
+      sortByMessageDeclaration
     />
   )
 }
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 483de99e4..1709f0288 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
-import {Image as RNImage} from 'react-native-image-crop-picker'
-import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
+import {type Image as RNImage} from 'react-native-image-crop-picker'
+import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 
@@ -26,15 +26,6 @@ export interface UserAddRemoveListsModal {
   onRemove?: (listUri: string) => void
 }
 
-export interface ListAddRemoveUsersModal {
-  name: 'list-add-remove-users'
-  list: AppBskyGraphDefs.ListView
-  onChange?: (
-    type: 'add' | 'remove',
-    profile: AppBskyActorDefs.ProfileViewBasic,
-  ) => void
-}
-
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
@@ -107,7 +98,6 @@ export type Modal =
   // Lists
   | CreateOrEditListModal
   | UserAddRemoveListsModal
-  | ListAddRemoveUsersModal
 
   // Posts
   | CropImageModal
diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts
index b93dc059d..410a613ad 100644
--- a/src/state/queries/list-memberships.ts
+++ b/src/state/queries/list-memberships.ts
@@ -90,7 +90,13 @@ export function getMembership(
   return membership ? membership.membershipUri : false
 }
 
-export function useListMembershipAddMutation() {
+export function useListMembershipAddMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess?: (data: {uri: string; cid: string}) => void
+  onError?: (error: Error) => void
+} = {}) {
   const {currentAccount} = useSession()
   const agent = useAgent()
   const queryClient = useQueryClient()
@@ -117,7 +123,7 @@ export function useListMembershipAddMutation() {
       // -prf
       return res
     },
-    onSuccess(data, variables) {
+    onSuccess: (data, variables) => {
       // manually update the cache; a refetch is too expensive
       let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
       if (memberships) {
@@ -145,11 +151,19 @@ export function useListMembershipAddMutation() {
           queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
         })
       }, 1e3)
+      onSuccess?.(data)
     },
+    onError,
   })
 }
 
-export function useListMembershipRemoveMutation() {
+export function useListMembershipRemoveMutation({
+  onSuccess,
+  onError,
+}: {
+  onSuccess?: (data: void) => void
+  onError?: (error: Error) => void
+} = {}) {
   const {currentAccount} = useSession()
   const agent = useAgent()
   const queryClient = useQueryClient()
@@ -172,7 +186,7 @@ export function useListMembershipRemoveMutation() {
       // query for that, so we use a timeout below
       // -prf
     },
-    onSuccess(data, variables) {
+    onSuccess: (data, variables) => {
       // manually update the cache; a refetch is too expensive
       let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
       if (memberships) {
@@ -192,6 +206,8 @@ export function useListMembershipRemoveMutation() {
           queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
         })
       }, 1e3)
+      onSuccess?.(data)
     },
+    onError,
   })
 }
diff --git a/src/view/com/modals/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
deleted file mode 100644
index 5285d4a15..000000000
--- a/src/view/com/modals/ListAddRemoveUsers.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-import React, {useCallback, useState} from 'react'
-import {
-  ActivityIndicator,
-  Pressable,
-  SafeAreaView,
-  StyleSheet,
-  View,
-} from 'react-native'
-import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {HITSLOP_20} from '#/lib/constants'
-import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {sanitizeDisplayName} from '#/lib/strings/display-names'
-import {cleanError} from '#/lib/strings/errors'
-import {sanitizeHandle} from '#/lib/strings/handles'
-import {colors, s} from '#/lib/styles'
-import {isWeb} from '#/platform/detection'
-import {useModalControls} from '#/state/modals'
-import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
-import {
-  getMembership,
-  ListMembersip,
-  useDangerousListMembershipsQuery,
-  useListMembershipAddMutation,
-  useListMembershipRemoveMutation,
-} from '#/state/queries/list-memberships'
-import {Button} from '../util/forms/Button'
-import {Text} from '../util/text/Text'
-import * as Toast from '../util/Toast'
-import {UserAvatar} from '../util/UserAvatar'
-import {ScrollView, TextInput} from './util'
-
-export const snapPoints = ['90%']
-
-export function Component({
-  list,
-  onChange,
-}: {
-  list: AppBskyGraphDefs.ListView
-  onChange?: (
-    type: 'add' | 'remove',
-    profile: AppBskyActorDefs.ProfileViewBasic,
-  ) => void
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const {isMobile} = useWebMediaQueries()
-  const [query, setQuery] = useState('')
-  const autocomplete = useActorAutocompleteQuery(query)
-  const {data: memberships} = useDangerousListMembershipsQuery()
-  const [isKeyboardVisible] = useIsKeyboardVisible()
-
-  const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery])
-
-  return (
-    <SafeAreaView
-      testID="listAddUserModal"
-      style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}>
-      <View style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
-        <View style={[styles.searchContainer, pal.border]}>
-          <FontAwesomeIcon icon="search" size={16} />
-          <TextInput
-            testID="searchInput"
-            style={[styles.searchInput, pal.border, pal.text]}
-            placeholder={_(msg`Search for users`)}
-            placeholderTextColor={pal.colors.textLight}
-            value={query}
-            onChangeText={setQuery}
-            accessible={true}
-            accessibilityLabel={_(msg`Search`)}
-            accessibilityHint=""
-            autoFocus
-            autoCapitalize="none"
-            autoComplete="off"
-            autoCorrect={false}
-            selectTextOnFocus
-          />
-          {query ? (
-            <Pressable
-              onPress={onPressCancelSearch}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Cancel search`)}
-              accessibilityHint={_(msg`Exits inputting search query`)}
-              onAccessibilityEscape={onPressCancelSearch}
-              hitSlop={HITSLOP_20}>
-              <FontAwesomeIcon
-                icon="xmark"
-                size={16}
-                color={pal.colors.textLight}
-              />
-            </Pressable>
-          ) : undefined}
-        </View>
-        <ScrollView
-          style={[s.flex1]}
-          keyboardDismissMode="none"
-          keyboardShouldPersistTaps="always">
-          {autocomplete.isLoading ? (
-            <View style={{marginVertical: 20}}>
-              <ActivityIndicator />
-            </View>
-          ) : autocomplete.data?.length ? (
-            <>
-              {autocomplete.data.slice(0, 40).map((item, i) => (
-                <UserResult
-                  key={item.did}
-                  list={list}
-                  profile={item}
-                  memberships={memberships}
-                  noBorder={i === 0}
-                  onChange={onChange}
-                />
-              ))}
-            </>
-          ) : (
-            <Text
-              type="xl"
-              style={[
-                pal.textLight,
-                {paddingHorizontal: 12, paddingVertical: 16},
-              ]}>
-              <Trans>No results found for {query}</Trans>
-            </Text>
-          )}
-        </ScrollView>
-        <View
-          style={[
-            styles.btnContainer,
-            {paddingBottom: isKeyboardVisible ? 10 : 20},
-          ]}>
-          <Button
-            testID="doneBtn"
-            type="default"
-            onPress={() => {
-              closeModal()
-            }}
-            accessibilityLabel={_(msg`Done`)}
-            accessibilityHint=""
-            label={_(msg({message: 'Done', context: 'action'}))}
-            labelContainerStyle={{justifyContent: 'center', padding: 4}}
-            labelStyle={[s.f18]}
-          />
-        </View>
-      </View>
-    </SafeAreaView>
-  )
-}
-
-function UserResult({
-  profile,
-  list,
-  memberships,
-  noBorder,
-  onChange,
-}: {
-  profile: AppBskyActorDefs.ProfileViewBasic
-  list: AppBskyGraphDefs.ListView
-  memberships: ListMembersip[] | undefined
-  noBorder: boolean
-  onChange?: (
-    type: 'add' | 'remove',
-    profile: AppBskyActorDefs.ProfileViewBasic,
-  ) => void | undefined
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const [isProcessing, setIsProcessing] = useState(false)
-  const membership = React.useMemo(
-    () => getMembership(memberships, list.uri, profile.did),
-    [memberships, list.uri, profile.did],
-  )
-  const listMembershipAddMutation = useListMembershipAddMutation()
-  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
-
-  const onToggleMembership = useCallback(async () => {
-    if (typeof membership === 'undefined') {
-      return
-    }
-    setIsProcessing(true)
-    try {
-      if (membership === false) {
-        await listMembershipAddMutation.mutateAsync({
-          listUri: list.uri,
-          actorDid: profile.did,
-        })
-        Toast.show(_(msg`Added to list`))
-        onChange?.('add', profile)
-      } else {
-        await listMembershipRemoveMutation.mutateAsync({
-          listUri: list.uri,
-          actorDid: profile.did,
-          membershipUri: membership,
-        })
-        Toast.show(_(msg`Removed from list`))
-        onChange?.('remove', profile)
-      }
-    } catch (e) {
-      Toast.show(cleanError(e), 'xmark')
-    } finally {
-      setIsProcessing(false)
-    }
-  }, [
-    _,
-    list,
-    profile,
-    membership,
-    setIsProcessing,
-    onChange,
-    listMembershipAddMutation,
-    listMembershipRemoveMutation,
-  ])
-
-  return (
-    <View
-      style={[
-        pal.border,
-        {
-          flexDirection: 'row',
-          alignItems: 'center',
-          borderTopWidth: noBorder ? 0 : 1,
-          paddingHorizontal: 8,
-        },
-      ]}>
-      <View
-        style={{
-          width: 54,
-          paddingLeft: 4,
-        }}>
-        <UserAvatar
-          size={40}
-          avatar={profile.avatar}
-          type={profile.associated?.labeler ? 'labeler' : 'user'}
-        />
-      </View>
-      <View
-        style={{
-          flex: 1,
-          paddingRight: 10,
-          paddingTop: 10,
-          paddingBottom: 10,
-        }}>
-        <Text
-          type="lg"
-          style={[s.bold, pal.text]}
-          numberOfLines={1}
-          lineHeight={1.2}>
-          {sanitizeDisplayName(
-            profile.displayName || sanitizeHandle(profile.handle),
-          )}
-        </Text>
-        <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-          {sanitizeHandle(profile.handle, '@')}
-        </Text>
-        {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
-      </View>
-      <View>
-        {isProcessing || typeof membership === 'undefined' ? (
-          <ActivityIndicator />
-        ) : (
-          <Button
-            testID={`user-${profile.handle}-addBtn`}
-            type="default"
-            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
-            onPress={onToggleMembership}
-          />
-        )}
-      </View>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  fixedHeight: {
-    // @ts-ignore web only -prf
-    height: '80vh',
-  },
-  titleSection: {
-    paddingTop: isWeb ? 0 : 4,
-    paddingBottom: isWeb ? 14 : 10,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: '600',
-    marginBottom: 5,
-  },
-  searchContainer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 8,
-    borderWidth: 1,
-    borderRadius: 24,
-    paddingHorizontal: 16,
-    paddingVertical: 10,
-  },
-  searchInput: {
-    fontSize: 16,
-    flex: 1,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.blue3,
-  },
-  btnContainer: {
-    paddingTop: 10,
-  },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 9ad651b4f..b4572172c 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -17,7 +17,6 @@ import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as LinkWarningModal from './LinkWarning'
-import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
 import * as VerifyEmailModal from './VerifyEmail'
 
@@ -61,9 +60,6 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'user-add-remove-lists') {
     snapPoints = UserAddRemoveListsModal.snapPoints
     element = <UserAddRemoveListsModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'list-add-remove-users') {
-    snapPoints = ListAddUserModal.snapPoints
-    element = <ListAddUserModal.Component {...activeModal} />
   } else if (activeModal?.name === 'delete-account') {
     snapPoints = DeleteAccountModal.snapPoints
     element = <DeleteAccountModal.Component />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 0c49c8771..74ee7c210 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -4,7 +4,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
 
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import type {Modal as ModalIface} from '#/state/modals'
+import {type Modal as ModalIface} from '#/state/modals'
 import {useModalControls, useModals} from '#/state/modals'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as ChangePasswordModal from './ChangePassword'
@@ -16,7 +16,6 @@ import * as InviteCodesModal from './InviteCodes'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as LinkWarningModal from './LinkWarning'
-import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
 import * as VerifyEmailModal from './VerifyEmail'
 
@@ -65,8 +64,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
     element = <UserAddRemoveLists.Component {...modal} />
-  } else if (modal.name === 'list-add-remove-users') {
-    element = <ListAddUserModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
   } else if (modal.name === 'delete-account') {
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 966534d97..61f1eb745 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -5,7 +5,7 @@ import {
   AppBskyGraphDefs,
   AtUri,
   moderateUserList,
-  ModerationOpts,
+  type ModerationOpts,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -21,8 +21,11 @@ import {useSetTitle} from '#/lib/hooks/useSetTitle'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {ComposeIcon2} from '#/lib/icons'
 import {makeListLink} from '#/lib/routes/links'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
-import {NavigationProp} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {shareUrl} from '#/lib/sharing'
 import {cleanError} from '#/lib/strings/errors'
 import {toShareUrl} from '#/lib/strings/url-helpers'
@@ -38,12 +41,12 @@ import {
   useListMuteMutation,
   useListQuery,
 } from '#/state/queries/list'
-import {FeedDescriptor} from '#/state/queries/post-feed'
+import {type FeedDescriptor} from '#/state/queries/post-feed'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {
   useAddSavedFeedsMutation,
   usePreferencesQuery,
-  UsePreferencesQueryResponse,
+  type UsePreferencesQueryResponse,
   useRemoveFeedMutation,
   useUpdateSavedFeedsMutation,
 } from '#/state/queries/preferences'
@@ -60,10 +63,10 @@ import {EmptyState} from '#/view/com/util/EmptyState'
 import {FAB} from '#/view/com/util/fab/FAB'
 import {Button} from '#/view/com/util/forms/Button'
 import {
-  DropdownItem,
+  type DropdownItem,
   NativeDropdown,
 } from '#/view/com/util/forms/NativeDropdown'
-import {ListRef} from '#/view/com/util/List'
+import {type ListRef} from '#/view/com/util/List'
 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
 import {LoadingScreen} from '#/view/com/util/LoadingScreen'
 import {Text} from '#/view/com/util/text/Text'
@@ -72,6 +75,7 @@ import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
 import {atoms as a} from '#/alf'
 import {Button as NewButton, ButtonIcon, ButtonText} from '#/components/Button'
 import {useDialogControl} from '#/components/Dialog'
+import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
 import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
 import * as Layout from '#/components/Layout'
 import * as Hider from '#/components/moderation/Hider'
@@ -157,12 +161,12 @@ function ProfileListScreenLoaded({
   const {rkey} = route.params
   const feedSectionRef = React.useRef<SectionRef>(null)
   const aboutSectionRef = React.useRef<SectionRef>(null)
-  const {openModal} = useModalControls()
   const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
   const isScreenFocused = useIsFocused()
   const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
   const isOwner = currentAccount?.did === list.creator.did
   const scrollElRef = useAnimatedRef()
+  const addUserDialogControl = useDialogControl()
   const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)]
 
   const moderation = React.useMemo(() => {
@@ -177,17 +181,11 @@ function ProfileListScreenLoaded({
     }, [setMinimalShellMode]),
   )
 
-  const onPressAddUser = useCallback(() => {
-    openModal({
-      name: 'list-add-remove-users',
-      list,
-      onChange() {
-        if (isCurateList) {
-          truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
-        }
-      },
-    })
-  }, [openModal, list, isCurateList, queryClient])
+  const onChangeMembers = useCallback(() => {
+    if (isCurateList) {
+      truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`))
+    }
+  }, [list.uri, isCurateList, queryClient])
 
   const onCurrentPageSelected = React.useCallback(
     (index: number) => {
@@ -225,7 +223,7 @@ function ProfileListScreenLoaded({
                   headerHeight={headerHeight}
                   isFocused={isScreenFocused && isFocused}
                   isOwner={isOwner}
-                  onPressAddUser={onPressAddUser}
+                  onPressAddUser={addUserDialogControl.open}
                 />
               )}
               {({headerHeight, scrollElRef}) => (
@@ -233,7 +231,7 @@ function ProfileListScreenLoaded({
                   ref={aboutSectionRef}
                   scrollElRef={scrollElRef as ListRef}
                   list={list}
-                  onPressAddUser={onPressAddUser}
+                  onPressAddUser={addUserDialogControl.open}
                   headerHeight={headerHeight}
                 />
               )}
@@ -253,6 +251,11 @@ function ProfileListScreenLoaded({
               accessibilityHint=""
             />
           </View>
+          <ListAddRemoveUsersDialog
+            control={addUserDialogControl}
+            list={list}
+            onChange={onChangeMembers}
+          />
         </Hider.Content>
       </Hider.Outer>
     )
@@ -268,7 +271,7 @@ function ProfileListScreenLoaded({
           <AboutSection
             list={list}
             scrollElRef={scrollElRef as ListRef}
-            onPressAddUser={onPressAddUser}
+            onPressAddUser={addUserDialogControl.open}
             headerHeight={0}
           />
           <FAB
@@ -286,6 +289,11 @@ function ProfileListScreenLoaded({
             accessibilityHint=""
           />
         </View>
+        <ListAddRemoveUsersDialog
+          control={addUserDialogControl}
+          list={list}
+          onChange={onChangeMembers}
+        />
       </Hider.Content>
     </Hider.Outer>
   )