about summary refs log tree commit diff
path: root/src/state/queries
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/queries')
-rw-r--r--src/state/queries/actor-autocomplete.ts66
-rw-r--r--src/state/queries/list-members.ts31
-rw-r--r--src/state/queries/list-memberships.ts190
-rw-r--r--src/state/queries/list.ts285
-rw-r--r--src/state/queries/my-follows.ts43
-rw-r--r--src/state/queries/my-lists.ts89
-rw-r--r--src/state/queries/profile-lists.ts31
7 files changed, 735 insertions, 0 deletions
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
new file mode 100644
index 000000000..18abb6314
--- /dev/null
+++ b/src/state/queries/actor-autocomplete.ts
@@ -0,0 +1,66 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {useMyFollowsQuery} from './my-follows'
+
+export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
+
+export function useActorAutocompleteQuery(prefix: string) {
+  const {agent} = useSession()
+  const {data: follows, isFetching} = useMyFollowsQuery()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(prefix || ''),
+    async queryFn() {
+      const res = await agent.searchActorsTypeahead({
+        term: prefix,
+        limit: 8,
+      })
+      return computeSuggestions(prefix, follows, res.data.actors)
+    },
+    enabled: !isFetching && !!prefix,
+  })
+}
+
+function computeSuggestions(
+  prefix: string,
+  follows: AppBskyActorDefs.ProfileViewBasic[] = [],
+  searched: AppBskyActorDefs.ProfileViewBasic[] = [],
+) {
+  if (prefix) {
+    const items: AppBskyActorDefs.ProfileViewBasic[] = []
+    for (const item of follows) {
+      if (prefixMatch(prefix, item)) {
+        items.push(item)
+      }
+      if (items.length >= 8) {
+        break
+      }
+    }
+    for (const item of searched) {
+      if (!items.find(item2 => item2.handle === item.handle)) {
+        items.push({
+          did: item.did,
+          handle: item.handle,
+          displayName: item.displayName,
+          avatar: item.avatar,
+        })
+      }
+    }
+    return items
+  } else {
+    return follows
+  }
+}
+
+function prefixMatch(
+  prefix: string,
+  info: AppBskyActorDefs.ProfileViewBasic,
+): boolean {
+  if (info.handle.includes(prefix)) {
+    return true
+  }
+  if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
+    return true
+  }
+  return false
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
new file mode 100644
index 000000000..ec5daec90
--- /dev/null
+++ b/src/state/queries/list-members.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetList} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (uri: string) => ['list-members', uri]
+
+export function useListMembersQuery(uri: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetList.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetList.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(uri),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}
diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts
new file mode 100644
index 000000000..f128c5867
--- /dev/null
+++ b/src/state/queries/list-memberships.ts
@@ -0,0 +1,190 @@
+/**
+ * NOTE
+ *
+ * This query is a temporary solution to our lack of server API for
+ * querying user membership in an API. It is extremely inefficient.
+ *
+ * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP!
+ * Use the list-members query for rendering a list's members.
+ *
+ * It works by fetching *all* of the user's list item records and querying
+ * or manipulating that cache. For users with large lists, it will fall
+ * down completely, so be very conservative about how you use it.
+ *
+ * -prf
+ */
+
+import {AtUri} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {RQKEY as LIST_MEMBERS_RQKEY} from './list-members'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 100,000k list members
+
+export const RQKEY = () => ['list-memberships']
+
+export interface ListMembersip {
+  membershipUri: string
+  listUri: string
+  actorDid: string
+}
+
+/**
+ * This API is dangerous! Read the note above!
+ */
+export function useDangerousListMembershipsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<ListMembersip[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: ListMembersip[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          limit: PAGE_SIZE,
+          cursor,
+        })
+        arr = arr.concat(
+          res.records.map(r => ({
+            membershipUri: r.uri,
+            listUri: r.value.list,
+            actorDid: r.value.subject,
+          })),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+      return arr
+    },
+  })
+}
+
+/**
+ * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record)
+ */
+export function getMembership(
+  memberships: ListMembersip[] | undefined,
+  list: string,
+  actor: string,
+): string | false | undefined {
+  if (!memberships) {
+    return undefined
+  }
+  const membership = memberships.find(
+    m => m.listUri === list && m.actorDid === actor,
+  )
+  return membership ? membership.membershipUri : false
+}
+
+export function useListMembershipAddMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    {listUri: string; actorDid: string}
+  >({
+    mutationFn: async ({listUri, actorDid}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const res = await agent.app.bsky.graph.listitem.create(
+        {repo: currentAccount.did},
+        {
+          subject: actorDid,
+          list: listUri,
+          createdAt: new Date().toISOString(),
+        },
+      )
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+      return res
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships
+          // avoid dups
+          .filter(
+            m =>
+              !(
+                m.actorDid === variables.actorDid &&
+                m.listUri === variables.listUri
+              ),
+          )
+          .concat([
+            {
+              ...variables,
+              membershipUri: data.uri,
+            },
+          ])
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
+
+export function useListMembershipRemoveMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    void,
+    Error,
+    {listUri: string; actorDid: string; membershipUri: string}
+  >({
+    mutationFn: async ({membershipUri}) => {
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      const membershipUrip = new AtUri(membershipUri)
+      await agent.app.bsky.graph.listitem.delete({
+        repo: currentAccount.did,
+        rkey: membershipUrip.rkey,
+      })
+      // TODO
+      // we need to wait for appview to update, but there's not an efficient
+      // query for that, so we use a timeout below
+      // -prf
+    },
+    onSuccess(data, variables) {
+      // manually update the cache; a refetch is too expensive
+      let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY())
+      if (memberships) {
+        memberships = memberships.filter(
+          m =>
+            !(
+              m.actorDid === variables.actorDid &&
+              m.listUri === variables.listUri
+            ),
+        )
+        queryClient.setQueryData(RQKEY(), memberships)
+      }
+      // invalidate the members queries (used for rendering the listings)
+      // use a timeout to wait for the appview (see above)
+      setTimeout(() => {
+        queryClient.invalidateQueries({
+          queryKey: LIST_MEMBERS_RQKEY(variables.listUri),
+        })
+      }, 1e3)
+    },
+  })
+}
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
new file mode 100644
index 000000000..4a46a4fbe
--- /dev/null
+++ b/src/state/queries/list.ts
@@ -0,0 +1,285 @@
+import {
+  AtUri,
+  AppBskyGraphGetList,
+  AppBskyGraphList,
+  AppBskyGraphDefs,
+  BskyAgent,
+} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import chunk from 'lodash.chunk'
+import {useSession} from '../session'
+import {invalidate as invalidateMyLists} from './my-lists'
+import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
+import {uploadBlob} from '#/lib/api'
+import {until} from '#/lib/async/until'
+
+export const RQKEY = (uri: string) => ['list', uri]
+
+export function useListQuery(uri?: string) {
+  const {agent} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView, Error>({
+    queryKey: RQKEY(uri || ''),
+    async queryFn() {
+      if (!uri) {
+        throw new Error('URI not provided')
+      }
+      const res = await agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      })
+      return res.data.list
+    },
+    enabled: !!uri,
+  })
+}
+
+export interface ListCreateMutateParams {
+  purpose: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListCreateMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
+    {
+      async mutationFn({purpose, name, description, avatar}) {
+        if (!currentAccount) {
+          throw new Error('Not logged in')
+        }
+        if (
+          purpose !== 'app.bsky.graph.defs#curatelist' &&
+          purpose !== 'app.bsky.graph.defs#modlist'
+        ) {
+          throw new Error('Invalid list purpose: must be curatelist or modlist')
+        }
+        const record: AppBskyGraphList.Record = {
+          purpose,
+          name,
+          description,
+          avatar: undefined,
+          createdAt: new Date().toISOString(),
+        }
+        if (avatar) {
+          const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+          record.avatar = blobRes.data.blob
+        }
+        const res = await agent.app.bsky.graph.list.create(
+          {
+            repo: currentAccount.did,
+          },
+          record,
+        )
+
+        // wait for the appview to update
+        await whenAppViewReady(
+          agent,
+          res.uri,
+          (v: AppBskyGraphGetList.Response) => {
+            return typeof v?.data?.list.uri === 'string'
+          },
+        )
+        return res
+      },
+      onSuccess() {
+        invalidateMyLists(queryClient)
+        queryClient.invalidateQueries({
+          queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+        })
+      },
+    },
+  )
+}
+
+export interface ListMetadataMutateParams {
+  uri: string
+  name: string
+  description: string
+  avatar: RNImage | null | undefined
+}
+export function useListMetadataMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<
+    {uri: string; cid: string},
+    Error,
+    ListMetadataMutateParams
+  >({
+    async mutationFn({uri, name, description, avatar}) {
+      const {hostname, rkey} = new AtUri(uri)
+      if (!currentAccount) {
+        throw new Error('Not logged in')
+      }
+      if (currentAccount.did !== hostname) {
+        throw new Error('You do not own this list')
+      }
+
+      // get the current record
+      const {value: record} = await agent.app.bsky.graph.list.get({
+        repo: currentAccount.did,
+        rkey,
+      })
+
+      // update the fields
+      record.name = name
+      record.description = description
+      if (avatar) {
+        const blobRes = await uploadBlob(agent, avatar.path, avatar.mime)
+        record.avatar = blobRes.data.blob
+      } else if (avatar === null) {
+        record.avatar = undefined
+      }
+      const res = (
+        await agent.com.atproto.repo.putRecord({
+          repo: currentAccount.did,
+          collection: 'app.bsky.graph.list',
+          rkey,
+          record,
+        })
+      ).data
+
+      // wait for the appview to update
+      await whenAppViewReady(
+        agent,
+        res.uri,
+        (v: AppBskyGraphGetList.Response) => {
+          const list = v.data.list
+          return (
+            list.name === record.name && list.description === record.description
+          )
+        },
+      )
+      return res
+    },
+    onSuccess(data, variables) {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListDeleteMutation() {
+  const {agent, currentAccount} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string}>({
+    mutationFn: async ({uri}) => {
+      if (!currentAccount) {
+        return
+      }
+      // fetch all the listitem records that belong to this list
+      let cursor
+      let listitemRecordUris: string[] = []
+      for (let i = 0; i < 100; i++) {
+        const res = await agent.app.bsky.graph.listitem.list({
+          repo: currentAccount.did,
+          cursor,
+          limit: 100,
+        })
+        listitemRecordUris = listitemRecordUris.concat(
+          res.records
+            .filter(record => record.value.list === uri)
+            .map(record => record.uri),
+        )
+        cursor = res.cursor
+        if (!cursor) {
+          break
+        }
+      }
+
+      // batch delete the list and listitem records
+      const createDel = (uri: string) => {
+        const urip = new AtUri(uri)
+        return {
+          $type: 'com.atproto.repo.applyWrites#delete',
+          collection: urip.collection,
+          rkey: urip.rkey,
+        }
+      }
+      const writes = listitemRecordUris
+        .map(uri => createDel(uri))
+        .concat([createDel(uri)])
+
+      // apply in chunks
+      for (const writesChunk of chunk(writes, 10)) {
+        await agent.com.atproto.repo.applyWrites({
+          repo: currentAccount.did,
+          writes: writesChunk,
+        })
+      }
+
+      // wait for the appview to update
+      await whenAppViewReady(agent, uri, (v: AppBskyGraphGetList.Response) => {
+        return !v?.success
+      })
+    },
+    onSuccess() {
+      invalidateMyLists(queryClient)
+      queryClient.invalidateQueries({
+        queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did),
+      })
+      // TODO!! /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri)
+    },
+  })
+}
+
+export function useListMuteMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; mute: boolean}>({
+    mutationFn: async ({uri, mute}) => {
+      if (mute) {
+        await agent.muteModList(uri)
+      } else {
+        await agent.unmuteModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+export function useListBlockMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, {uri: string; block: boolean}>({
+    mutationFn: async ({uri, block}) => {
+      if (block) {
+        await agent.blockModList(uri)
+      } else {
+        await agent.unblockModList(uri)
+      }
+    },
+    onSuccess(data, variables) {
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.uri),
+      })
+    },
+  })
+}
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  uri: string,
+  fn: (res: AppBskyGraphGetList.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () =>
+      agent.app.bsky.graph.getList({
+        list: uri,
+        limit: 1,
+      }),
+  )
+}
diff --git a/src/state/queries/my-follows.ts b/src/state/queries/my-follows.ts
new file mode 100644
index 000000000..ad6cf837d
--- /dev/null
+++ b/src/state/queries/my-follows.ts
@@ -0,0 +1,43 @@
+import {AppBskyActorDefs} from '@atproto/api'
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records
+const SANITY_PAGE_LIMIT = 1000
+const PAGE_SIZE = 100
+// ...which comes 10,000k follows
+
+export const RQKEY = () => ['my-follows']
+
+export function useMyFollowsQuery() {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
+    queryKey: RQKEY(),
+    async queryFn() {
+      if (!currentAccount) {
+        return []
+      }
+      let cursor
+      let arr: AppBskyActorDefs.ProfileViewBasic[] = []
+      for (let i = 0; i < SANITY_PAGE_LIMIT; i++) {
+        const res = await agent.getFollows({
+          actor: currentAccount.did,
+          cursor,
+          limit: PAGE_SIZE,
+        })
+        // TODO
+        // res.data.follows = res.data.follows.filter(
+        //   profile =>
+        //     !moderateProfile(profile, this.rootStore.preferences.moderationOpts)
+        //       .account.filter,
+        // )
+        arr = arr.concat(res.data.follows)
+        if (!res.data.cursor) {
+          break
+        }
+        cursor = res.data.cursor
+      }
+      return arr
+    },
+  })
+}
diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts
new file mode 100644
index 000000000..d412cff02
--- /dev/null
+++ b/src/state/queries/my-lists.ts
@@ -0,0 +1,89 @@
+import {AppBskyGraphDefs} from '@atproto/api'
+import {useQuery, QueryClient} from '@tanstack/react-query'
+import {accumulate} from 'lib/async/accumulate'
+import {useSession} from '../session'
+
+export type MyListsFilter = 'all' | 'curate' | 'mod'
+export const RQKEY = (filter: MyListsFilter) => ['my-lists', filter]
+
+export function useMyListsQuery(filter: MyListsFilter) {
+  const {agent, currentAccount} = useSession()
+  return useQuery<AppBskyGraphDefs.ListView[]>({
+    queryKey: RQKEY(filter),
+    async queryFn() {
+      let lists: AppBskyGraphDefs.ListView[] = []
+      const promises = [
+        accumulate(cursor =>
+          agent.app.bsky.graph
+            .getLists({
+              actor: currentAccount!.did,
+              cursor,
+              limit: 50,
+            })
+            .then(res => ({
+              cursor: res.data.cursor,
+              items: res.data.lists,
+            })),
+        ),
+      ]
+      if (filter === 'all' || filter === 'mod') {
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListMutes({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+        promises.push(
+          accumulate(cursor =>
+            agent.app.bsky.graph
+              .getListBlocks({
+                cursor,
+                limit: 50,
+              })
+              .then(res => ({
+                cursor: res.data.cursor,
+                items: res.data.lists,
+              })),
+          ),
+        )
+      }
+      const resultset = await Promise.all(promises)
+      for (const res of resultset) {
+        for (let list of res) {
+          if (
+            filter === 'curate' &&
+            list.purpose !== 'app.bsky.graph.defs#curatelist'
+          ) {
+            continue
+          }
+          if (
+            filter === 'mod' &&
+            list.purpose !== 'app.bsky.graph.defs#modlist'
+          ) {
+            continue
+          }
+          if (!lists.find(l => l.uri === list.uri)) {
+            lists.push(list)
+          }
+        }
+      }
+      return lists
+    },
+    enabled: !!currentAccount,
+  })
+}
+
+export function invalidate(qc: QueryClient, filter?: MyListsFilter) {
+  if (filter) {
+    qc.invalidateQueries({queryKey: RQKEY(filter)})
+  } else {
+    qc.invalidateQueries({queryKey: ['my-lists']})
+  }
+}
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
new file mode 100644
index 000000000..a277a6d61
--- /dev/null
+++ b/src/state/queries/profile-lists.ts
@@ -0,0 +1,31 @@
+import {AppBskyGraphGetLists} from '@atproto/api'
+import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+export const RQKEY = (did: string) => ['profile-lists', did]
+
+export function useProfileListsQuery(did: string) {
+  const {agent} = useSession()
+  return useInfiniteQuery<
+    AppBskyGraphGetLists.OutputSchema,
+    Error,
+    InfiniteData<AppBskyGraphGetLists.OutputSchema>,
+    QueryKey,
+    RQPageParam
+  >({
+    queryKey: RQKEY(did),
+    async queryFn({pageParam}: {pageParam: RQPageParam}) {
+      const res = await agent.app.bsky.graph.getLists({
+        actor: did,
+        limit: PAGE_SIZE,
+        cursor: pageParam,
+      })
+      return res.data
+    },
+    initialPageParam: undefined,
+    getNextPageParam: lastPage => lastPage.cursor,
+  })
+}