about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/lists/ListCard.tsx6
-rw-r--r--src/view/com/lists/ListMembers.tsx (renamed from src/view/com/lists/ListItems.tsx)94
-rw-r--r--src/view/com/lists/ListsList.tsx70
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx32
-rw-r--r--src/view/com/modals/ListAddRemoveUsers.tsx (renamed from src/view/com/modals/ListAddUser.tsx)134
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx4
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx309
8 files changed, 311 insertions, 342 deletions
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
index a481902d8..774e9e916 100644
--- a/src/view/com/lists/ListCard.tsx
+++ b/src/view/com/lists/ListCard.tsx
@@ -7,7 +7,7 @@ import {RichText as RichTextCom} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
+import {useSession} from '#/state/session'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
@@ -28,7 +28,7 @@ export const ListCard = ({
   style?: StyleProp<ViewStyle>
 }) => {
   const pal = usePalette('default')
-  const store = useStores()
+  const {currentAccount} = useSession()
 
   const rkey = React.useMemo(() => {
     try {
@@ -80,7 +80,7 @@ export const ListCard = ({
             {list.purpose === 'app.bsky.graph.defs#modlist' &&
               'Moderation list '}
             by{' '}
-            {list.creator.did === store.me.did
+            {list.creator.did === currentAccount?.did
               ? 'you'
               : sanitizeHandle(list.creator.handle, '@')}
           </Text>
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx
index cf6fd3b42..4a25c53e6 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -9,27 +9,28 @@ import {
 } from 'react-native'
 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {FlatList} from '../util/Views'
-import {observer} from 'mobx-react-lite'
 import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {ProfileCard} from '../profile/ProfileCard'
 import {Button} from '../util/forms/Button'
-import {ListModel} from 'state/models/content/list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useListMembersQuery} from '#/state/queries/list-members'
 import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
 import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useSession} from '#/state/session'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING_ITEM = {_reactKey: '__loading__'}
 const EMPTY_ITEM = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListItems = observer(function ListItemsImpl({
+export function ListMembers({
   list,
   style,
   scrollElRef,
@@ -42,7 +43,7 @@ export const ListItems = observer(function ListItemsImpl({
   headerOffset = 0,
   desktopFixedHeightOffset,
 }: {
-  list: ListModel
+  list: string
   style?: StyleProp<ViewStyle>
   scrollElRef?: MutableRefObject<FlatList<any> | null>
   onScroll: OnScrollHandler
@@ -59,33 +60,43 @@ export const ListItems = observer(function ListItemsImpl({
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
   const {openModal} = useModalControls()
+  const {currentAccount} = useSession()
 
-  const data = React.useMemo(() => {
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useListMembersQuery(list)
+  const isEmpty = !isFetching && !data?.pages[0].items.length
+  const isOwner =
+    currentAccount && data?.pages[0].list.creator.did === currentAccount.did
+
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (list.hasLoaded) {
-      if (list.hasError) {
+    if (isFetched) {
+      if (isEmpty && isError) {
         items = items.concat([ERROR_ITEM])
       }
-      if (list.isEmpty) {
+      if (isEmpty) {
         items = items.concat([EMPTY_ITEM])
-      } else {
-        items = items.concat(list.items)
+      } else if (data) {
+        for (const page of data.pages) {
+          items = items.concat(page.items)
+        }
       }
-      if (list.loadMoreError) {
+      if (!isEmpty && isError) {
         items = items.concat([LOAD_MORE_ERROR_ITEM])
       }
-    } else if (list.isLoading) {
+    } else if (isFetching) {
       items = items.concat([LOADING_ITEM])
     }
     return items
-  }, [
-    list.hasError,
-    list.hasLoaded,
-    list.isLoading,
-    list.isEmpty,
-    list.items,
-    list.loadMoreError,
-  ])
+  }, [isFetched, isEmpty, isError, data, isFetching])
 
   // events
   // =
@@ -94,25 +105,26 @@ export const ListItems = observer(function ListItemsImpl({
     track('Lists:onRefresh')
     setIsRefreshing(true)
     try {
-      await list.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
-  }, [list, track, setIsRefreshing])
+  }, [refetch, track, setIsRefreshing])
 
   const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
     track('Lists:onEndReached')
     try {
-      await list.loadMore()
+      await fetchNextPage()
     } catch (err) {
       logger.error('Failed to load more lists', {error: err})
     }
-  }, [list, track])
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
 
   const onPressRetryLoadMore = React.useCallback(() => {
-    list.retryLoadMore()
-  }, [list])
+    fetchNextPage()
+  }, [fetchNextPage])
 
   const onPressEditMembership = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
@@ -120,19 +132,9 @@ export const ListItems = observer(function ListItemsImpl({
         name: 'user-add-remove-lists',
         subject: profile.did,
         displayName: profile.displayName || profile.handle,
-        onAdd(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheAddMember(profile)
-          }
-        },
-        onRemove(listUri: string) {
-          if (listUri === list.uri) {
-            list.cacheRemoveMember(profile)
-          }
-        },
       })
     },
-    [openModal, list],
+    [openModal],
   )
 
   // rendering
@@ -140,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({
 
   const renderMemberButton = React.useCallback(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      if (!list.isOwner) {
+      if (!isOwner) {
         return null
       }
       return (
@@ -152,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({
         />
       )
     },
-    [list, onPressEditMembership],
+    [isOwner, onPressEditMembership],
   )
 
   const renderItem = React.useCallback(
@@ -162,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={list.error}
+            message={cleanError(error)}
             onPressTryAgain={onPressTryAgain}
           />
         )
@@ -190,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({
     [
       renderMemberButton,
       renderEmptyState,
-      list.error,
+      error,
       onPressTryAgain,
       onPressRetryLoadMore,
       isMobile,
@@ -200,10 +202,10 @@ export const ListItems = observer(function ListItemsImpl({
   const Footer = React.useCallback(
     () => (
       <View style={{paddingTop: 20, paddingBottom: 200}}>
-        {list.isLoading && <ActivityIndicator />}
+        {isFetching && <ActivityIndicator />}
       </View>
     ),
-    [list.isLoading],
+    [isFetching],
   )
 
   const scrollHandler = useAnimatedScrollHandler(onScroll)
@@ -212,8 +214,8 @@ export const ListItems = observer(function ListItemsImpl({
       <FlatList
         testID={testID ? `${testID}-flatlist` : undefined}
         ref={scrollElRef}
-        data={data}
-        keyExtractor={(item: any) => item._reactKey}
+        data={items}
+        keyExtractor={(item: any) => item.uri || item._reactKey}
         renderItem={renderItem}
         ListHeaderComponent={renderHeader}
         ListFooterComponent={Footer}
@@ -241,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({
       />
     </View>
   )
-})
+}
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
index 2883a31d5..100e0d609 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/ListsList.tsx
@@ -8,68 +8,59 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {ListCard} from './ListCard'
+import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {Text} from '../util/text/Text'
-import {ListsListModel} from 'state/models/lists/lists-list'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
 import {FlatList} from '../util/Views'
 import {s} from 'lib/styles'
 import {logger} from '#/logger'
 import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
 
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
 const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export const ListsList = observer(function ListsListImpl({
-  listsList,
+export function ListsList({
+  filter,
   inline,
   style,
-  onPressTryAgain,
   renderItem,
   testID,
 }: {
-  listsList: ListsListModel
+  filter: MyListsFilter
   inline?: boolean
   style?: StyleProp<ViewStyle>
-  onPressTryAgain?: () => void
   renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element
   testID?: string
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const {data, isFetching, isFetched, isError, error, refetch} =
+    useMyListsQuery(filter)
+  const isEmpty = !isFetching && !data?.length
 
-  const data = React.useMemo(() => {
+  const items = React.useMemo(() => {
     let items: any[] = []
-    if (listsList.hasError) {
+    if (isError && isEmpty) {
       items = items.concat([ERROR_ITEM])
     }
-    if (!listsList.hasLoaded && listsList.isLoading) {
+    if (!isFetched && isFetching) {
       items = items.concat([LOADING])
-    } else if (listsList.isEmpty) {
+    } else if (isEmpty) {
       items = items.concat([EMPTY])
     } else {
-      items = items.concat(listsList.lists)
-    }
-    if (listsList.loadMoreError) {
-      items = items.concat([LOAD_MORE_ERROR_ITEM])
+      items = items.concat(data)
     }
     return items
-  }, [
-    listsList.hasError,
-    listsList.hasLoaded,
-    listsList.isLoading,
-    listsList.lists,
-    listsList.isEmpty,
-    listsList.loadMoreError,
-  ])
+  }, [isError, isEmpty, isFetched, isFetching, data])
 
   // events
   // =
@@ -78,25 +69,12 @@ export const ListsList = observer(function ListsListImpl({
     track('Lists:onRefresh')
     setIsRefreshing(true)
     try {
-      await listsList.refresh()
+      await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
-  }, [listsList, track, setIsRefreshing])
-
-  const onEndReached = React.useCallback(async () => {
-    track('Lists:onEndReached')
-    try {
-      await listsList.loadMore()
-    } catch (err) {
-      logger.error('Failed to load more lists', {error: err})
-    }
-  }, [listsList, track])
-
-  const onPressRetryLoadMore = React.useCallback(() => {
-    listsList.retryLoadMore()
-  }, [listsList])
+  }, [refetch, track, setIsRefreshing])
 
   // rendering
   // =
@@ -116,15 +94,15 @@ export const ListsList = observer(function ListsListImpl({
       } else if (item === ERROR_ITEM) {
         return (
           <ErrorMessage
-            message={listsList.error}
-            onPressTryAgain={onPressTryAgain}
+            message={cleanError(error)}
+            onPressTryAgain={onRefresh}
           />
         )
       } else if (item === LOAD_MORE_ERROR_ITEM) {
         return (
           <LoadMoreRetryBtn
             label="There was an issue fetching your lists. Tap here to try again."
-            onPress={onPressRetryLoadMore}
+            onPress={onRefresh}
           />
         )
       } else if (item === LOADING) {
@@ -144,16 +122,16 @@ export const ListsList = observer(function ListsListImpl({
         />
       )
     },
-    [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal],
+    [error, onRefresh, renderItem, pal],
   )
 
   const FlatListCom = inline ? RNFlatList : FlatList
   return (
     <View testID={testID} style={style}>
-      {data.length > 0 && (
+      {items.length > 0 && (
         <FlatListCom
           testID={testID ? `${testID}-flatlist` : undefined}
-          data={data}
+          data={items}
           keyExtractor={(item: any) => item._reactKey}
           renderItem={renderItemInner}
           refreshControl={
@@ -165,8 +143,6 @@ export const ListsList = observer(function ListsListImpl({
             />
           }
           contentContainerStyle={[s.contentContainer]}
-          onEndReached={onEndReached}
-          onEndReachedThreshold={0.6}
           removeClippedSubviews={true}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
@@ -174,7 +150,7 @@ export const ListsList = observer(function ListsListImpl({
       )}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   item: {
diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index cfd0f7569..8d13cdf2f 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -1,5 +1,4 @@
 import React, {useState, useCallback, useMemo} from 'react'
-import * as Toast from '../util/Toast'
 import {
   ActivityIndicator,
   KeyboardAvoidingView,
@@ -9,12 +8,12 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {AppBskyGraphDefs} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
+import * as Toast from '../util/Toast'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {compressIfNeeded} from 'lib/media/manip'
@@ -27,6 +26,10 @@ import {cleanError, isNetworkError} from 'lib/strings/errors'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useListCreateMutation,
+  useListMetadataMutation,
+} from '#/state/queries/list'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -40,9 +43,8 @@ export function Component({
 }: {
   purpose?: string
   onSave?: (uri: string) => void
-  list?: ListModel
+  list?: AppBskyGraphDefs.ListView
 }) {
-  const store = useStores()
   const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [error, setError] = useState<string>('')
@@ -50,10 +52,12 @@ export function Component({
   const theme = useTheme()
   const {track} = useAnalytics()
   const {_} = useLingui()
+  const listCreateMutation = useListCreateMutation()
+  const listMetadataMutation = useListMetadataMutation()
 
   const activePurpose = useMemo(() => {
-    if (list?.data?.purpose) {
-      return list.data.purpose
+    if (list?.purpose) {
+      return list.purpose
     }
     if (purpose) {
       return purpose
@@ -64,11 +68,11 @@ export function Component({
   const purposeLabel = isCurateList ? 'User' : 'Moderation'
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
-  const [name, setName] = useState<string>(list?.data?.name || '')
+  const [name, setName] = useState<string>(list?.name || '')
   const [description, setDescription] = useState<string>(
-    list?.data?.description || '',
+    list?.description || '',
   )
-  const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar)
+  const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
   const onPressCancel = useCallback(() => {
@@ -111,7 +115,8 @@ export function Component({
     }
     try {
       if (list) {
-        await list.updateMetadata({
+        await listMetadataMutation.mutateAsync({
+          uri: list.uri,
           name: nameTrimmed,
           description: description.trim(),
           avatar: newAvatar,
@@ -119,7 +124,7 @@ export function Component({
         Toast.show(`${purposeLabel} list updated`)
         onSave?.(list.uri)
       } else {
-        const res = await ListModel.createList(store, {
+        const res = await listCreateMutation.mutateAsync({
           purpose: activePurpose,
           name,
           description,
@@ -145,7 +150,6 @@ export function Component({
     setError,
     error,
     onSave,
-    store,
     closeModal,
     activePurpose,
     isCurateList,
@@ -154,6 +158,8 @@ export function Component({
     description,
     newAvatar,
     list,
+    listMetadataMutation,
+    listCreateMutation,
   ])
 
   return (
diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
index e59ab90df..d34194c41 100644
--- a/src/view/com/modals/ListAddUser.tsx
+++ b/src/view/com/modals/ListAddRemoveUsers.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useCallback, useState, useMemo} from 'react'
+import React, {useCallback, useState} from 'react'
 import {
   ActivityIndicator,
   Pressable,
@@ -6,17 +6,13 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {ScrollView, TextInput} from './util'
-import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {Button} from '../util/forms/Button'
 import {UserAvatar} from '../util/UserAvatar'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
-import {ListModel} from 'state/models/content/list'
-import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb} from 'platform/detection'
@@ -29,49 +25,37 @@ import {HITSLOP_20} from '#/lib/constants'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 
 export const snapPoints = ['90%']
 
-export const Component = observer(function Component({
+export function Component({
   list,
-  onAdd,
+  onChange,
 }: {
-  list: ListModel
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
+  list: AppBskyGraphDefs.ListView
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void
 }) {
   const pal = usePalette('default')
-  const store = useStores()
   const {_} = useLingui()
   const {closeModal} = useModalControls()
   const {isMobile} = useWebMediaQueries()
   const [query, setQuery] = useState('')
-  const autocompleteView = useMemo<UserAutocompleteModel>(
-    () => new UserAutocompleteModel(store),
-    [store],
-  )
+  const autocomplete = useActorAutocompleteQuery(query)
+  const {data: memberships} = useDangerousListMembershipsQuery()
   const [isKeyboardVisible] = useIsKeyboardVisible()
 
-  // initial setup
-  useEffect(() => {
-    autocompleteView.setup().then(() => {
-      autocompleteView.setPrefix('')
-    })
-    autocompleteView.setActive(true)
-    list.loadAll()
-  }, [autocompleteView, list])
-
-  const onChangeQuery = useCallback(
-    (text: string) => {
-      setQuery(text)
-      autocompleteView.setPrefix(text)
-    },
-    [setQuery, autocompleteView],
-  )
-
-  const onPressCancelSearch = useCallback(
-    () => onChangeQuery(''),
-    [onChangeQuery],
-  )
+  const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery])
 
   return (
     <SafeAreaView
@@ -86,7 +70,7 @@ export const Component = observer(function Component({
             placeholder="Search for users"
             placeholderTextColor={pal.colors.textLight}
             value={query}
-            onChangeText={onChangeQuery}
+            onChangeText={setQuery}
             accessible={true}
             accessibilityLabel={_(msg`Search`)}
             accessibilityHint=""
@@ -116,19 +100,20 @@ export const Component = observer(function Component({
           style={[s.flex1]}
           keyboardDismissMode="none"
           keyboardShouldPersistTaps="always">
-          {autocompleteView.isLoading ? (
+          {autocomplete.isLoading ? (
             <View style={{marginVertical: 20}}>
               <ActivityIndicator />
             </View>
-          ) : autocompleteView.suggestions.length ? (
+          ) : autocomplete.data?.length ? (
             <>
-              {autocompleteView.suggestions.slice(0, 40).map((item, i) => (
+              {autocomplete.data.slice(0, 40).map((item, i) => (
                 <UserResult
                   key={item.did}
                   list={list}
                   profile={item}
+                  memberships={memberships}
                   noBorder={i === 0}
-                  onAdd={onAdd}
+                  onChange={onChange}
                 />
               ))}
             </>
@@ -139,7 +124,7 @@ export const Component = observer(function Component({
                 pal.textLight,
                 {paddingHorizontal: 12, paddingVertical: 16},
               ]}>
-              <Trans>No results found for {autocompleteView.prefix}</Trans>
+              <Trans>No results found for {query}</Trans>
             </Text>
           )}
         </ScrollView>
@@ -162,36 +147,71 @@ export const Component = observer(function Component({
       </View>
     </SafeAreaView>
   )
-})
+}
 
 function UserResult({
   profile,
   list,
+  memberships,
   noBorder,
-  onAdd,
+  onChange,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
-  list: ListModel
+  list: AppBskyGraphDefs.ListView
+  memberships: ListMembersip[] | undefined
   noBorder: boolean
-  onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined
+  onChange?: (
+    type: 'add' | 'remove',
+    profile: AppBskyActorDefs.ProfileViewBasic,
+  ) => void | undefined
 }) {
   const pal = usePalette('default')
+  const {_} = useLingui()
   const [isProcessing, setIsProcessing] = useState(false)
-  const [isAdded, setIsAdded] = useState(list.isMember(profile.did))
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, profile.did),
+    [memberships, list.uri, profile.did],
+  )
+  const listMembershipAddMutation = useListMembershipAddMutation()
+  const listMembershipRemoveMutation = useListMembershipRemoveMutation()
 
-  const onPressAdd = useCallback(async () => {
+  const onToggleMembership = useCallback(async () => {
+    if (typeof membership === 'undefined') {
+      return
+    }
     setIsProcessing(true)
     try {
-      await list.addMember(profile)
-      Toast.show('Added to list')
-      setIsAdded(true)
-      onAdd?.(profile)
+      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))
     } finally {
       setIsProcessing(false)
     }
-  }, [list, profile, setIsProcessing, setIsAdded, onAdd])
+  }, [
+    _,
+    list,
+    profile,
+    membership,
+    setIsProcessing,
+    onChange,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
 
   return (
     <View
@@ -233,16 +253,14 @@ function UserResult({
         {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
       </View>
       <View>
-        {isAdded ? (
-          <FontAwesomeIcon icon="check" />
-        ) : isProcessing ? (
+        {isProcessing || typeof membership === 'undefined' ? (
           <ActivityIndicator />
         ) : (
           <Button
             testID={`user-${profile.handle}-addBtn`}
             type="default"
-            label="Add"
-            onPress={onPressAdd}
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
       </View>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index c1999c5d6..38c8bc7ba 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -18,7 +18,7 @@ import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveListsModal from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
 import * as ReportModal from './report/Modal'
@@ -108,7 +108,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'user-add-remove-lists') {
     snapPoints = UserAddRemoveListsModal.snapPoints
     element = <UserAddRemoveListsModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'list-add-user') {
+  } else if (activeModal?.name === 'list-add-remove-users') {
     snapPoints = ListAddUserModal.snapPoints
     element = <ListAddUserModal.Component {...activeModal} />
   } else if (activeModal?.name === 'delete-account') {
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 65c4ee444..28f6c36c9 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput'
 import * as ReportModal from './report/Modal'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
-import * as ListAddUserModal from './ListAddUser'
+import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as SelfLabelModal from './SelfLabel'
@@ -85,7 +85,7 @@ 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-user') {
+  } else if (modal.name === 'list-add-remove-users') {
     element = <ListAddUserModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index efcfc43be..73b1bc744 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -1,33 +1,32 @@
 import React, {useCallback} from 'react'
-import {observer} from 'mobx-react-lite'
-import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
 import {ListsList} from '../lists/ListsList'
-import {ListsListModel} from 'state/models/lists/lists-list'
-import {ListMembershipModel} from 'state/models/content/list-membership'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
-import {useStores} from 'state/index'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {s} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
-import isEqual from 'lodash.isequal'
-import {logger} from '#/logger'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {
+  useDangerousListMembershipsQuery,
+  getMembership,
+  ListMembersip,
+  useListMembershipAddMutation,
+  useListMembershipRemoveMutation,
+} from '#/state/queries/list-memberships'
+import {cleanError} from '#/lib/strings/errors'
+import {useSession} from '#/state/session'
 
 export const snapPoints = ['fullscreen']
 
-export const Component = observer(function UserAddRemoveListsImpl({
+export function Component({
   subject,
   displayName,
   onAdd,
@@ -38,193 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({
   onAdd?: (listUri: string) => void
   onRemove?: (listUri: string) => void
 }) {
-  const store = useStores()
   const {closeModal} = useModalControls()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const palPrimary = usePalette('primary')
-  const palInverted = usePalette('inverted')
-  const [originalSelections, setOriginalSelections] = React.useState<string[]>(
-    [],
-  )
-  const [selected, setSelected] = React.useState<string[]>([])
-  const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
+  const {data: memberships} = useDangerousListMembershipsQuery()
 
-  const listsList: ListsListModel = React.useMemo(
-    () => new ListsListModel(store, store.me.did),
-    [store],
-  )
-  const memberships: ListMembershipModel = React.useMemo(
-    () => new ListMembershipModel(store, subject),
-    [store, subject],
-  )
-  React.useEffect(() => {
-    listsList.refresh()
-    memberships.fetch().then(
-      () => {
-        const ids = memberships.memberships.map(m => m.value.list)
-        setOriginalSelections(ids)
-        setSelected(ids)
-        setMembershipsLoaded(true)
-      },
-      err => {
-        logger.error('Failed to fetch memberships', {error: err})
-      },
-    )
-  }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
-
-  const onPressCancel = useCallback(() => {
+  const onPressDone = useCallback(() => {
     closeModal()
   }, [closeModal])
 
-  const onPressSave = useCallback(async () => {
-    let changes
-    try {
-      changes = await memberships.updateTo(selected)
-    } catch (err) {
-      logger.error('Failed to update memberships', {error: err})
-      return
-    }
-    Toast.show('Lists updated')
-    for (const uri of changes.added) {
-      onAdd?.(uri)
-    }
-    for (const uri of changes.removed) {
-      onRemove?.(uri)
-    }
-    closeModal()
-  }, [closeModal, selected, memberships, onAdd, onRemove])
-
-  const onToggleSelected = useCallback(
-    (uri: string) => {
-      if (selected.includes(uri)) {
-        setSelected(selected.filter(uri2 => uri2 !== uri))
-      } else {
-        setSelected([...selected, uri])
-      }
-    },
-    [selected, setSelected],
-  )
-
-  const renderItem = useCallback(
-    (list: GraphDefs.ListView, index: number) => {
-      const isSelected = selected.includes(list.uri)
-      return (
-        <Pressable
-          testID={`toggleBtn-${list.name}`}
-          style={[
-            styles.listItem,
-            pal.border,
-            {
-              opacity: membershipsLoaded ? 1 : 0.5,
-              borderTopWidth: index === 0 ? 0 : 1,
-            },
-          ]}
-          accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
-            list.name
-          }`}
-          accessibilityHint=""
-          disabled={!membershipsLoaded}
-          onPress={() => onToggleSelected(list.uri)}>
-          <View style={styles.listItemAvi}>
-            <UserAvatar size={40} avatar={list.avatar} />
-          </View>
-          <View style={styles.listItemContent}>
-            <Text
-              type="lg"
-              style={[s.bold, pal.text]}
-              numberOfLines={1}
-              lineHeight={1.2}>
-              {sanitizeDisplayName(list.name)}
-            </Text>
-            <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-              {list.purpose === 'app.bsky.graph.defs#curatelist' &&
-                'User list '}
-              {list.purpose === 'app.bsky.graph.defs#modlist' &&
-                'Moderation list '}
-              by{' '}
-              {list.creator.did === store.me.did
-                ? 'you'
-                : sanitizeHandle(list.creator.handle, '@')}
-            </Text>
-          </View>
-          {membershipsLoaded && (
-            <View
-              style={
-                isSelected
-                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
-                  : [styles.checkbox, pal.borderDark]
-              }>
-              {isSelected && (
-                <FontAwesomeIcon
-                  icon="check"
-                  style={palInverted.text as FontAwesomeIconStyle}
-                />
-              )}
-            </View>
-          )}
-        </Pressable>
-      )
-    },
-    [
-      pal,
-      palPrimary,
-      palInverted,
-      onToggleSelected,
-      selected,
-      store.me.did,
-      membershipsLoaded,
-    ],
-  )
-
-  // Only show changes button if there are some items on the list to choose from AND user has made changes in selection
-  const canSaveChanges =
-    !listsList.isEmpty && !isEqual(selected, originalSelections)
-
   return (
     <View testID="userAddRemoveListsModal" style={s.hContentRegion}>
       <Text style={[styles.title, pal.text]}>
         <Trans>Update {displayName} in Lists</Trans>
       </Text>
       <ListsList
-        listsList={listsList}
+        filter="all"
         inline
-        renderItem={renderItem}
+        renderItem={(list, index) => (
+          <ListItem
+            index={index}
+            list={list}
+            memberships={memberships}
+            subject={subject}
+            onAdd={onAdd}
+            onRemove={onRemove}
+          />
+        )}
         style={[styles.list, pal.border]}
       />
       <View style={[styles.btns, pal.border]}>
         <Button
-          testID="cancelBtn"
+          testID="doneBtn"
           type="default"
-          onPress={onPressCancel}
+          onPress={onPressDone}
           style={styles.footerBtn}
-          accessibilityLabel={_(msg`Cancel`)}
+          accessibilityLabel={_(msg`Done`)}
           accessibilityHint=""
-          onAccessibilityEscape={onPressCancel}
-          label="Cancel"
+          onAccessibilityEscape={onPressDone}
+          label="Done"
         />
-        {canSaveChanges && (
+      </View>
+    </View>
+  )
+}
+
+function ListItem({
+  index,
+  list,
+  memberships,
+  subject,
+  onAdd,
+  onRemove,
+}: {
+  index: number
+  list: GraphDefs.ListView
+  memberships: ListMembersip[] | undefined
+  subject: string
+  onAdd?: (listUri: string) => void
+  onRemove?: (listUri: string) => void
+}) {
+  const pal = usePalette('default')
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+  const membership = React.useMemo(
+    () => getMembership(memberships, list.uri, subject),
+    [memberships, list.uri, subject],
+  )
+  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: subject,
+        })
+        Toast.show(_(msg`Added to list`))
+        onAdd?.(list.uri)
+      } else {
+        await listMembershipRemoveMutation.mutateAsync({
+          listUri: list.uri,
+          actorDid: subject,
+          membershipUri: membership,
+        })
+        Toast.show(_(msg`Removed from list`))
+        onRemove?.(list.uri)
+      }
+    } catch (e) {
+      Toast.show(cleanError(e))
+    } finally {
+      setIsProcessing(false)
+    }
+  }, [
+    _,
+    list,
+    subject,
+    membership,
+    setIsProcessing,
+    onAdd,
+    onRemove,
+    listMembershipAddMutation,
+    listMembershipRemoveMutation,
+  ])
+
+  return (
+    <View
+      testID={`toggleBtn-${list.name}`}
+      style={[
+        styles.listItem,
+        pal.border,
+        {
+          borderTopWidth: index === 0 ? 0 : 1,
+        },
+      ]}>
+      <View style={styles.listItemAvi}>
+        <UserAvatar size={40} avatar={list.avatar} />
+      </View>
+      <View style={styles.listItemContent}>
+        <Text
+          type="lg"
+          style={[s.bold, pal.text]}
+          numberOfLines={1}
+          lineHeight={1.2}>
+          {sanitizeDisplayName(list.name)}
+        </Text>
+        <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+          {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
+          {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '}
+          by{' '}
+          {list.creator.did === currentAccount?.did
+            ? 'you'
+            : sanitizeHandle(list.creator.handle, '@')}
+        </Text>
+      </View>
+      <View>
+        {isProcessing || typeof membership === 'undefined' ? (
+          <ActivityIndicator />
+        ) : (
           <Button
-            testID="saveBtn"
-            type="primary"
-            onPress={onPressSave}
-            style={styles.footerBtn}
-            accessibilityLabel={_(msg`Save changes`)}
-            accessibilityHint=""
-            onAccessibilityEscape={onPressSave}
-            label="Save Changes"
+            testID={`user-${subject}-addBtn`}
+            type="default"
+            label={membership === false ? _(msg`Add`) : _(msg`Remove`)}
+            onPress={onToggleMembership}
           />
         )}
-
-        {(listsList.isLoading || !membershipsLoaded) && (
-          <View style={styles.loadingContainer}>
-            <ActivityIndicator />
-          </View>
-        )}
       </View>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   container: {