diff options
Diffstat (limited to 'src/state/queries')
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 66 | ||||
-rw-r--r-- | src/state/queries/list-members.ts | 31 | ||||
-rw-r--r-- | src/state/queries/list-memberships.ts | 190 | ||||
-rw-r--r-- | src/state/queries/list.ts | 285 | ||||
-rw-r--r-- | src/state/queries/my-follows.ts | 43 | ||||
-rw-r--r-- | src/state/queries/my-lists.ts | 89 | ||||
-rw-r--r-- | src/state/queries/profile-lists.ts | 31 |
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, + }) +} |