From d9e0a927c1c98ebd6aa3885ab517af27e7de2522 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Sun, 12 Nov 2023 12:45:25 -0800 Subject: Refactor lists to use new queries (#1875) * Refactor lists queries to react-query * Delete old lists-list model * Implement list, list-members, and list-memberships react-queries * Update CreateOrEditList modal * First pass at my-follows and actor-autocomplete queries * Update ListAddUserModal to use new queries, change to ListAddRemoveUsersModal * Update UserAddRemoveLists modal * Remove old TODO * Fix indent, autocomplete query * Add a todo --------- Co-authored-by: Eric Bailey --- src/lib/api/index.ts | 11 +- src/state/modals/index.tsx | 18 +- src/state/models/content/list-membership.ts | 130 ------- src/state/models/content/list.ts | 508 ---------------------------- src/state/models/content/profile.ts | 4 +- src/state/models/lists/lists-list.ts | 244 ------------- src/state/models/ui/profile.ts | 1 - src/state/queries/actor-autocomplete.ts | 66 ++++ src/state/queries/list-members.ts | 31 ++ src/state/queries/list-memberships.ts | 190 +++++++++++ src/state/queries/list.ts | 285 ++++++++++++++++ src/state/queries/my-follows.ts | 43 +++ src/state/queries/my-lists.ts | 89 +++++ src/state/queries/profile-lists.ts | 31 ++ src/view/com/lists/ListCard.tsx | 6 +- src/view/com/lists/ListItems.tsx | 244 ------------- src/view/com/lists/ListMembers.tsx | 246 ++++++++++++++ src/view/com/lists/ListsList.tsx | 70 ++-- src/view/com/modals/CreateOrEditList.tsx | 32 +- src/view/com/modals/ListAddRemoveUsers.tsx | 309 +++++++++++++++++ src/view/com/modals/ListAddUser.tsx | 291 ---------------- src/view/com/modals/Modal.tsx | 4 +- src/view/com/modals/Modal.web.tsx | 4 +- src/view/com/modals/UserAddRemoveLists.tsx | 309 ++++++++--------- src/view/screens/Lists.tsx | 18 +- src/view/screens/ModerationModlists.tsx | 18 +- src/view/screens/ProfileList.tsx | 494 ++++++++++++--------------- 27 files changed, 1727 insertions(+), 1969 deletions(-) delete mode 100644 src/state/models/content/list-membership.ts delete mode 100644 src/state/models/content/list.ts delete mode 100644 src/state/models/lists/lists-list.ts create mode 100644 src/state/queries/actor-autocomplete.ts create mode 100644 src/state/queries/list-members.ts create mode 100644 src/state/queries/list-memberships.ts create mode 100644 src/state/queries/list.ts create mode 100644 src/state/queries/my-follows.ts create mode 100644 src/state/queries/my-lists.ts create mode 100644 src/state/queries/profile-lists.ts delete mode 100644 src/view/com/lists/ListItems.tsx create mode 100644 src/view/com/lists/ListMembers.tsx create mode 100644 src/view/com/modals/ListAddRemoveUsers.tsx delete mode 100644 src/view/com/modals/ListAddUser.tsx (limited to 'src') diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9d48a78c0..a98834888 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,6 +4,7 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyRichtextFacet, + BskyAgent, ComAtprotoLabelDefs, ComAtprotoRepoUploadBlob, RichText, @@ -53,18 +54,18 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { } export async function uploadBlob( - store: RootStoreModel, + agent: BskyAgent, blob: string, encoding: string, ): Promise { if (isWeb) { // `blob` should be a data uri - return store.agent.uploadBlob(convertDataURIToUint8Array(blob), { + return agent.uploadBlob(convertDataURIToUint8Array(blob), { encoding, }) } else { // `blob` should be a path to a file in the local FS - return store.agent.uploadBlob( + return agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) @@ -135,7 +136,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - const res = await uploadBlob(store, path, 'image/jpeg') + const res = await uploadBlob(store.agent, path, 'image/jpeg') images.push({ image: res.data.blob, alt: image.altText ?? '', @@ -185,7 +186,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (encoding) { const thumbUploadRes = await uploadBlob( - store, + store.agent, opts.extLink.localThumb.path, encoding, ) 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({ + 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, + 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({ + 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(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(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({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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, + 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, + }) +} 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 }) => { 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, '@')} diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx deleted file mode 100644 index cf6fd3b42..000000000 --- a/src/view/com/lists/ListItems.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, {MutableRefObject} from 'react' -import { - ActivityIndicator, - Dimensions, - RefreshControl, - StyleProp, - View, - ViewStyle, -} 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 {OnScrollHandler} from 'lib/hooks/useOnMainScroll' -import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' -import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' - -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({ - list, - style, - scrollElRef, - onScroll, - onPressTryAgain, - renderHeader, - renderEmptyState, - testID, - scrollEventThrottle, - headerOffset = 0, - desktopFixedHeightOffset, -}: { - list: ListModel - style?: StyleProp - scrollElRef?: MutableRefObject | null> - onScroll: OnScrollHandler - onPressTryAgain?: () => void - renderHeader: () => JSX.Element - renderEmptyState: () => JSX.Element - testID?: string - scrollEventThrottle?: number - headerOffset?: number - desktopFixedHeightOffset?: number -}) { - const pal = usePalette('default') - const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) - const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - - const data = React.useMemo(() => { - let items: any[] = [] - if (list.hasLoaded) { - if (list.hasError) { - items = items.concat([ERROR_ITEM]) - } - if (list.isEmpty) { - items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) - } - if (list.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) - } - } else if (list.isLoading) { - items = items.concat([LOADING_ITEM]) - } - return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) - - // events - // = - - const onRefresh = React.useCallback(async () => { - track('Lists:onRefresh') - setIsRefreshing(true) - try { - await list.refresh() - } catch (err) { - logger.error('Failed to refresh lists', {error: err}) - } - setIsRefreshing(false) - }, [list, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await list.loadMore() - } catch (err) { - logger.error('Failed to load more lists', {error: err}) - } - }, [list, track]) - - const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) - - const onPressEditMembership = React.useCallback( - (profile: AppBskyActorDefs.ProfileViewBasic) => { - openModal({ - 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], - ) - - // rendering - // = - - const renderMemberButton = React.useCallback( - (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { - return null - } - return ( - - + ) - }), + }, ) diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 3892e47c7..098d93cdc 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,11 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' import {ListsList} from 'view/com/lists/ListsList' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' @@ -21,24 +18,17 @@ import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps export const ModerationModlistsScreen = withAuthRequired( - observer(function ModerationModlistsScreenImpl({}: Props) { + function ModerationModlistsScreenImpl({}: Props) { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() const {openModal} = useModalControls() - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) - useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) - mutelists.refresh() - }, [mutelists, setMinimalShellMode]), + }, [setMinimalShellMode]), ) const onPressNewList = React.useCallback(() => { @@ -89,8 +79,8 @@ export const ModerationModlistsScreen = withAuthRequired( - + ) - }), + }, ) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 03c1703c1..18665f519 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -10,8 +10,7 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' -import {RichText as RichTextAPI} from '@atproto/api' +import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' @@ -28,7 +27,6 @@ import * as Toast from 'view/com/util/Toast' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Haptics} from 'lib/haptics' -import {ListModel} from 'state/models/content/list' import {FeedDescriptor} from '#/state/queries/post-feed' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -39,17 +37,24 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {resolveName} from 'lib/api' import {s} from 'lib/styles' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink, makeListLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' -import {ListItems} from 'view/com/lists/ListItems' -import {logger} from '#/logger' +import {ListMembers} from '#/view/com/lists/ListMembers' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + useListQuery, + useListMuteMutation, + useListBlockMutation, + useListDeleteMutation, +} from '#/state/queries/list' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -60,40 +65,32 @@ interface SectionRef { type Props = NativeStackScreenProps export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl(props: Props) { - const store = useStores() - const {name: handleOrDid} = props.route.params - const [listOwnerDid, setListOwnerDid] = React.useState() - const [error, setError] = React.useState() - - React.useEffect(() => { - /* - * We must resolve the DID of the list owner before we can fetch the list. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setListOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setListOwnerDid]) + function ProfileListScreenImpl(props: Props) { + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: list, error: listError} = useListQuery(resolvedUri) - if (error) { + if (resolveError) { + return ( + + + + ) + } + if (listError) { return ( - + ) } - return listOwnerDid ? ( - + return resolvedUri && list ? ( + ) : ( @@ -101,192 +98,172 @@ export const ProfileListScreen = withAuthRequired( ) - }), + }, ) -export const ProfileListScreenInner = observer( - function ProfileListScreenInnerImpl({ - route, - listOwnerDid, - }: Props & {listOwnerDid: string}) { - const store = useStores() - const {_} = useLingui() - const queryClient = useQueryClient() - const setMinimalShellMode = useSetMinimalShellMode() - const {rkey} = route.params - const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}` - const feedSectionRef = React.useRef(null) - const aboutSectionRef = React.useRef(null) - const {openModal} = useModalControls() - - const list: ListModel = useMemo(() => { - const model = new ListModel(store, listUri) - return model - }, [store, listUri]) - useSetTitle(list.data?.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - list.loadMore(true) - }, [setMinimalShellMode, list]), - ) - - const onPressAddUser = useCallback(() => { - openModal({ - name: 'list-add-user', - list, - onAdd() { - if (list.isCuratelist) { - queryClient.invalidateQueries({ - queryKey: FEED_RQKEY(`list|${listUri}`), - }) - } - }, - }) - }, [openModal, list, queryClient, listUri]) +function ProfileListScreenLoaded({ + route, + uri, + list, +}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + const store = useStores() + const {_} = useLingui() + const queryClient = useQueryClient() + const setMinimalShellMode = useSetMinimalShellMode() + const {rkey} = route.params + const feedSectionRef = React.useRef(null) + const aboutSectionRef = React.useRef(null) + const {openModal} = useModalControls() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + + useSetTitle(list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - if (index === 1) { - aboutSectionRef.current?.scrollToTop() + const onPressAddUser = useCallback(() => { + openModal({ + name: 'list-add-remove-users', + list, + onChange() { + if (isCurateList) { + queryClient.invalidateQueries({ + // TODO(eric) should construct these strings with a fn too + queryKey: FEED_RQKEY(`list|${list.uri}`), + }) } }, - [feedSectionRef], - ) + }) + }, [openModal, list, isCurateList, queryClient]) - const renderHeader = useCallback(() => { - return
- }, [rkey, list]) + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - if (list.isCuratelist) { - return ( - - - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - | null> - } - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - | null> - } - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - - store.shell.openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - - ) - } - if (list.isModlist) { - return ( - - - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - | null> - } - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - - store.shell.openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - - ) - } + const renderHeader = useCallback(() => { + return
+ }, [rkey, list]) + + if (isCurateList) { return ( - -
- {list.error ? : null} - + + + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + | null> + } + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + | null> + } + list={list} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + + store.shell.openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + ) - }, -) + } + return ( + + + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + | null> + } + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + + store.shell.openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + ) +} -const Header = observer(function HeaderImpl({ - rkey, - list, -}: { - rkey: string - list: ListModel -}) { +function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const pal = usePalette('default') const palInverted = usePalette('inverted') const {_} = useLingui() const navigation = useNavigation() + const {currentAccount} = useSession() const {openModal, closeModal} = useModalControls() + const listMuteMutation = useListMuteMutation() + const listBlockMutation = useListBlockMutation() + const listDeleteMutation = useListDeleteMutation() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isModList = list.purpose === 'app.bsky.graph.defs#modlist' + const isPinned = false // TODO + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isOwner = list.creator.did === currentAccount?.did const onTogglePinned = useCallback(async () => { Haptics.default() - list.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned list', {error: e}) - }) - }, [list]) + // TODO + // list.togglePin().catch(e => { + // Toast.show('There was an issue contacting the server') + // logger.error('Failed to toggle pinned list', {error: e}) + // }) + }, []) const onSubscribeMute = useCallback(() => { openModal({ @@ -297,7 +274,7 @@ const Header = observer(function HeaderImpl({ confirmBtnText: 'Mute this List', async onPressConfirm() { try { - await list.mute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) Toast.show('List muted') } catch { Toast.show( @@ -309,18 +286,18 @@ const Header = observer(function HeaderImpl({ closeModal() }, }) - }, [openModal, closeModal, list]) + }, [openModal, closeModal, list, listMuteMutation]) const onUnsubscribeMute = useCallback(async () => { try { - await list.unmute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show('List unmuted') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listMuteMutation]) const onSubscribeBlock = useCallback(() => { openModal({ @@ -331,7 +308,7 @@ const Header = observer(function HeaderImpl({ confirmBtnText: 'Block this List', async onPressConfirm() { try { - await list.block() + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show('List blocked') } catch { Toast.show( @@ -343,26 +320,23 @@ const Header = observer(function HeaderImpl({ closeModal() }, }) - }, [openModal, closeModal, list]) + }, [openModal, closeModal, list, listBlockMutation]) const onUnsubscribeBlock = useCallback(async () => { try { - await list.unblock() + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show('List unblocked') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listBlockMutation]) const onPressEdit = useCallback(() => { openModal({ name: 'create-or-edit-list', list, - onSave() { - list.refresh() - }, }) }, [openModal, list]) @@ -372,7 +346,7 @@ const Header = observer(function HeaderImpl({ title: 'Delete List', message: 'Are you sure?', async onPressConfirm() { - await list.delete() + await listDeleteMutation.mutateAsync({uri: list.uri}) Toast.show('List deleted') if (navigation.canGoBack()) { navigation.goBack() @@ -381,26 +355,22 @@ const Header = observer(function HeaderImpl({ } }, }) - }, [openModal, list, navigation]) + }, [openModal, list, listDeleteMutation, navigation]) const onPressReport = useCallback(() => { - if (!list.data) return openModal({ name: 'report', uri: list.uri, - cid: list.data.cid, + cid: list.cid, }) }, [openModal, list]) const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) - }, [list.creatorDid, rkey]) + }, [list, rkey]) const dropdownItems: DropdownItem[] = useMemo(() => { - if (!list.hasLoaded) { - return [] - } let items: DropdownItem[] = [ { testID: 'listHeaderDropdownShareBtn', @@ -415,7 +385,7 @@ const Header = observer(function HeaderImpl({ }, }, ] - if (list.isOwner) { + if (isOwner) { items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownEditBtn', @@ -457,14 +427,7 @@ const Header = observer(function HeaderImpl({ }) } return items - }, [ - list.hasLoaded, - list.isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, - ]) + }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { return [ @@ -497,32 +460,28 @@ const Header = observer(function HeaderImpl({ return ( - {list.isCuratelist || list.isPinned ? ( + {isCurateList || isPinned ? (