diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/modals/index.tsx | 18 | ||||
-rw-r--r-- | src/state/models/content/list-membership.ts | 130 | ||||
-rw-r--r-- | src/state/models/content/list.ts | 508 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 4 | ||||
-rw-r--r-- | src/state/models/lists/lists-list.ts | 244 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 1 | ||||
-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 |
13 files changed, 747 insertions, 893 deletions
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 287bbe593..6c63d9fc1 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,11 +1,10 @@ import React from 'react' -import {AppBskyActorDefs, ModerationUI} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native' import {Image as RNImage} from 'react-native-image-crop-picker' import {ProfileModel} from '#/state/models/content/profile' import {ImageModel} from '#/state/models/media/image' -import {ListModel} from '#/state/models/content/list' import {GalleryModel} from '#/state/models/media/gallery' export interface ConfirmModal { @@ -55,7 +54,7 @@ export type ReportModal = { export interface CreateOrEditListModal { name: 'create-or-edit-list' purpose?: string - list?: ListModel + list?: AppBskyGraphDefs.ListView onSave?: (uri: string) => void } @@ -67,10 +66,13 @@ export interface UserAddRemoveListsModal { onRemove?: (listUri: string) => void } -export interface ListAddUserModal { - name: 'list-add-user' - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void +export interface ListAddRemoveUsersModal { + name: 'list-add-remove-users' + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void } export interface EditImageModal { @@ -184,7 +186,7 @@ export type Modal = // Lists | CreateOrEditListModal | UserAddRemoveListsModal - | ListAddUserModal + | ListAddRemoveUsersModal // Posts | AltTextImageModal diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts deleted file mode 100644 index 135d34dd5..000000000 --- a/src/state/models/content/list-membership.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AtUri, AppBskyGraphListitem} from '@atproto/api' -import {runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' - -const PAGE_SIZE = 100 -interface Membership { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListMembershipModel { - // data - memberships: Membership[] = [] - - constructor(public rootStore: RootStoreModel, public subject: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async fetch() { - // NOTE - // this approach to determining list membership is too inefficient to work at any scale - // it needs to be replaced with server side list membership queries - // -prf - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.subject === this.subject), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - runInAction(() => { - this.memberships = records - }) - } - - getMembership(listUri: string) { - return this.memberships.find(m => m.value.list === listUri) - } - - isMember(listUri: string) { - return !!this.getMembership(listUri) - } - - async add(listUri: string) { - if (this.isMember(listUri)) { - return - } - const res = await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.subject, - list: listUri, - createdAt: new Date().toISOString(), - }, - ) - const {rkey} = new AtUri(res.uri) - const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.concat([record]) - }) - } - - async remove(listUri: string) { - const membership = this.getMembership(listUri) - if (!membership) { - return - } - const {rkey} = new AtUri(membership.uri) - await this.rootStore.agent.app.bsky.graph.listitem.delete({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.filter(m => m.value.list !== listUri) - }) - } - - async updateTo( - uris: string[], - ): Promise<{added: string[]; removed: string[]}> { - const added = [] - const removed = [] - for (const uri of uris) { - await this.add(uri) - added.push(uri) - } - for (const membership of this.memberships) { - if (!uris.includes(membership.value.list)) { - await this.remove(membership.value.list) - removed.push(membership.value.list) - } - } - return {added, removed} - } -} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts deleted file mode 100644 index fc09eeb9f..000000000 --- a/src/state/models/content/list.ts +++ /dev/null @@ -1,508 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - AppBskyActorDefs, - AppBskyGraphGetList as GetList, - AppBskyGraphDefs as GraphDefs, - AppBskyGraphList, - AppBskyGraphListitem, - RichText, -} from '@atproto/api' -import {Image as RNImage} from 'react-native-image-crop-picker' -import chunk from 'lodash.chunk' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {track} from 'lib/analytics/analytics' -import {until} from 'lib/async/until' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - // data - data: GraphDefs.ListView | null = null - items: GraphDefs.ListItemView[] = [] - descriptionRT: RichText | null = null - - static async createList( - rootStore: RootStoreModel, - { - purpose, - name, - description, - avatar, - }: { - purpose: string - name: string - description: string - avatar: RNImage | null | undefined - }, - ) { - 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 apilib.uploadBlob( - rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } - const res = await rootStore.agent.app.bsky.graph.list.create( - { - repo: rootStore.me.did, - }, - record, - ) - - // wait for the appview to update - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - (v: GetList.Response, _e: any) => { - return typeof v?.data?.list.uri === 'string' - }, - () => - rootStore.agent.app.bsky.graph.getList({ - list: res.uri, - limit: 1, - }), - ) - return res - } - - constructor(public rootStore: RootStoreModel, public uri: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.items.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get isCuratelist() { - return this.data?.purpose === 'app.bsky.graph.defs#curatelist' - } - - get isModlist() { - return this.data?.purpose === 'app.bsky.graph.defs#modlist' - } - - get isOwner() { - return this.data?.creator.did === this.rootStore.me.did - } - - get isBlocking() { - return !!this.data?.viewer?.blocked - } - - get isMuting() { - return !!this.data?.viewer?.muted - } - - get isPinned() { - return this.rootStore.preferences.isPinnedFeed(this.uri) - } - - get creatorDid() { - return this.data?.creator.did - } - - getMembership(did: string) { - return this.items.find(item => item.subject.did === did) - } - - isMember(did: string) { - return !!this.getMembership(did) - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - await this._resolveUri() - const res = await this.rootStore.agent.app.bsky.graph.getList({ - list: this.uri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(replace ? e : undefined, !replace ? e : undefined) - } - }) - - async loadAll() { - for (let i = 0; i < 1000; i++) { - if (!this.hasMore) { - break - } - await this.loadMore() - } - } - - async updateMetadata({ - name, - description, - avatar, - }: { - name: string - description: string - avatar: RNImage | null | undefined - }) { - if (!this.data) { - return - } - if (!this.isOwner) { - throw new Error('Cannot edit this list') - } - await this._resolveUri() - - // get the current record - const {rkey} = new AtUri(this.uri) - const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ - repo: this.rootStore.me.did, - rkey, - }) - - // update the fields - record.name = name - record.description = description - if (avatar) { - const blobRes = await apilib.uploadBlob( - this.rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } else if (avatar === null) { - record.avatar = undefined - } - return await this.rootStore.agent.com.atproto.repo.putRecord({ - repo: this.rootStore.me.did, - collection: 'app.bsky.graph.list', - rkey, - record, - }) - } - - async delete() { - if (!this.data) { - return - } - await this._resolveUri() - - // fetch all the listitem records that belong to this list - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.list === this.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 = records - .map(record => createDel(record.uri)) - .concat([createDel(this.uri)]) - - // apply in chunks - for (const writesChunk of chunk(writes, 10)) { - await this.rootStore.agent.com.atproto.repo.applyWrites({ - repo: this.rootStore.me.did, - writes: writesChunk, - }) - } - - /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri) - this.rootStore.emitListDeleted(this.uri) - } - - async addMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - return - } - await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: profile.did, - list: this.uri, - createdAt: new Date().toISOString(), - }, - ) - runInAction(() => { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - }) - } - - /** - * Just adds to local cache; used to reflect changes affected elsewhere - */ - cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (!this.isMember(profile.did)) { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - } - } - - /** - * Just removes from local cache; used to reflect changes affected elsewhere - */ - cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - this.items = this.items.filter(item => item.subject.did !== profile.did) - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - logger.error('Failed to pin feed', {error}) - } finally { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - } - } - - async togglePin() { - if (!this.isPinned) { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - return this.rootStore.preferences.addPinnedFeed(this.uri) - } else { - track('CustomFeed:Unpin', { - name: this.data?.name || '', - uri: this.uri, - }) - // TODO TEMPORARY - // lists are temporarily piggybacking on the saved/pinned feeds preferences - // we'll eventually replace saved feeds with the bookmarks API - // until then, we need to unsave lists instead of just unpin them - // -prf - // return this.rootStore.preferences.removePinnedFeed(this.uri) - return this.rootStore.preferences.removeSavedFeed(this.uri) - } - } - - async mute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.muteModList(this.data.uri) - track('Lists:Mute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: true}} - } - }) - } - - async unmute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.unmuteModList(this.data.uri) - track('Lists:Unmute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: false}} - } - }) - } - - async block() { - if (!this.data) { - return - } - await this._resolveUri() - const res = await this.rootStore.agent.blockModList(this.data.uri) - track('Lists:Block') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}} - } - }) - } - - async unblock() { - if (!this.data || !this.data.viewer?.blocked) { - return - } - await this._resolveUri() - await this.rootStore.agent.unblockModList(this.data.uri) - track('Lists:Unblock') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}} - } - }) - } - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any, loadMoreErr?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - this.loadMoreError = cleanError(loadMoreErr) - if (err) { - logger.error('Failed to fetch user items', {error: err}) - } - if (loadMoreErr) { - logger.error('Failed to fetch user items', { - error: loadMoreErr, - }) - } - } - - // helper functions - // = - - async _resolveUri() { - const urip = new AtUri(this.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - runInAction(() => { - this.error = e.toString() - }) - } - } - runInAction(() => { - this.uri = urip.toString() - }) - } - - _replaceAll(res: GetList.Response) { - this.items = [] - this._appendAll(res) - } - - _appendAll(res: GetList.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.data = res.data.list - this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject.did})), - ) - if (this.data.description) { - this.descriptionRT = new RichText({ - text: this.data.description, - facets: (this.data.descriptionFacets || [])?.slice(), - }) - } else { - this.descriptionRT = null - } - } -} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 14362ceec..2abb9bfb5 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -158,7 +158,7 @@ export class ProfileModel { existing.description = updates.description if (newUserAvatar) { const res = await apilib.uploadBlob( - this.rootStore, + this.rootStore.agent, newUserAvatar.path, newUserAvatar.mime, ) @@ -168,7 +168,7 @@ export class ProfileModel { } if (newUserBanner) { const res = await apilib.uploadBlob( - this.rootStore, + this.rootStore.agent, newUserBanner.path, newUserBanner.mime, ) diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts deleted file mode 100644 index eb6291637..000000000 --- a/src/state/models/lists/lists-list.ts +++ /dev/null @@ -1,244 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {accumulate} from 'lib/async/accumulate' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export class ListsListModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - // data - lists: GraphDefs.ListView[] = [] - - constructor( - public rootStore: RootStoreModel, - public source: 'mine' | 'my-curatelists' | 'my-modlists' | string, - ) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.lists.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get curatelists() { - return this.lists.filter( - list => list.purpose === 'app.bsky.graph.defs#curatelist', - ) - } - - get isCuratelistsEmpty() { - return this.hasLoaded && this.curatelists.length === 0 - } - - get modlists() { - return this.lists.filter( - list => list.purpose === 'app.bsky.graph.defs#modlist', - ) - } - - get isModlistsEmpty() { - return this.hasLoaded && this.modlists.length === 0 - } - - /** - * Removes posts from the feed upon deletion. - */ - onListDeleted(uri: string) { - this.lists = this.lists.filter(l => l.uri !== uri) - } - - // public api - // = - - /** - * Register any event listeners. Returns a cleanup function. - */ - registerListeners() { - const sub = this.rootStore.onListDeleted(this.onListDeleted.bind(this)) - return () => sub.remove() - } - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - let cursor: string | undefined - let lists: GraphDefs.ListView[] = [] - if ( - this.source === 'mine' || - this.source === 'my-curatelists' || - this.source === 'my-modlists' - ) { - const promises = [ - accumulate(cursor => - this.rootStore.agent.app.bsky.graph - .getLists({ - actor: this.rootStore.me.did, - cursor, - limit: 50, - }) - .then(res => ({cursor: res.data.cursor, items: res.data.lists})), - ), - ] - if (this.source === 'my-modlists') { - promises.push( - accumulate(cursor => - this.rootStore.agent.app.bsky.graph - .getListMutes({ - cursor, - limit: 50, - }) - .then(res => ({ - cursor: res.data.cursor, - items: res.data.lists, - })), - ), - ) - promises.push( - accumulate(cursor => - this.rootStore.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 ( - this.source === 'my-curatelists' && - list.purpose !== 'app.bsky.graph.defs#curatelist' - ) { - continue - } - if ( - this.source === 'my-modlists' && - list.purpose !== 'app.bsky.graph.defs#modlist' - ) { - continue - } - if (!lists.find(l => l.uri === list.uri)) { - lists.push(list) - } - } - } - } else { - const res = await this.rootStore.agent.app.bsky.graph.getLists({ - actor: this.source, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - lists = res.data.lists - cursor = res.data.cursor - } - if (replace) { - this._replaceAll({lists, cursor}) - } else { - this._appendAll({lists, cursor}) - } - this._xIdle() - } catch (e: any) { - this._xIdle(replace ? e : undefined, !replace ? e : undefined) - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any, loadMoreErr?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - this.loadMoreError = cleanError(loadMoreErr) - if (err) { - logger.error('Failed to fetch user lists', {error: err}) - } - if (loadMoreErr) { - logger.error('Failed to fetch user lists', { - error: loadMoreErr, - }) - } - } - - // helper functions - // = - - _replaceAll({ - lists, - cursor, - }: { - lists: GraphDefs.ListView[] - cursor: string | undefined - }) { - this.lists = [] - this._appendAll({lists, cursor}) - } - - _appendAll({ - lists, - cursor, - }: { - lists: GraphDefs.ListView[] - cursor: string | undefined - }) { - this.loadMoreCursor = cursor - this.hasMore = !!this.loadMoreCursor - this.lists = this.lists.concat( - lists.map(list => ({...list, _reactKey: list.uri})), - ) - } -} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 0ef592928..d6ea0c084 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,7 +2,6 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {ActorFeedsModel} from '../lists/actor-feeds' -import {ListsListModel} from '../lists/lists-list' import {logger} from '#/logger' export enum Sections { 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, + }) +} |