diff options
Diffstat (limited to 'src/state/models/content')
-rw-r--r-- | src/state/models/content/feed-source.ts | 223 | ||||
-rw-r--r-- | src/state/models/content/list-membership.ts | 9 | ||||
-rw-r--r-- | src/state/models/content/list.ts | 230 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 3 |
4 files changed, 439 insertions, 26 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts new file mode 100644 index 000000000..8dac9b56f --- /dev/null +++ b/src/state/models/content/feed-source.ts @@ -0,0 +1,223 @@ +import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api' +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {track} from 'lib/analytics/analytics' + +export class FeedSourceModel { + // state + _reactKey: string + hasLoaded = false + error: string | undefined + + // data + uri: string + cid: string = '' + type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported' + avatar: string | undefined = '' + displayName: string = '' + descriptionRT: RichText | null = null + creatorDid: string = '' + creatorHandle: string = '' + likeCount: number | undefined = 0 + likeUri: string | undefined = '' + + constructor(public rootStore: RootStoreModel, uri: string) { + this._reactKey = uri + this.uri = uri + + try { + const urip = new AtUri(uri) + if (urip.collection === 'app.bsky.feed.generator') { + this.type = 'feed-generator' + } else if (urip.collection === 'app.bsky.graph.list') { + this.type = 'list' + } + } catch {} + this.displayName = uri.split('/').pop() || '' + + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get href() { + const urip = new AtUri(this.uri) + const collection = + urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' + return `/profile/${urip.hostname}/${collection}/${urip.rkey}` + } + + get isSaved() { + return this.rootStore.preferences.savedFeeds.includes(this.uri) + } + + get isPinned() { + return this.rootStore.preferences.isPinnedFeed(this.uri) + } + + get isLiked() { + return !!this.likeUri + } + + get isOwner() { + return this.creatorDid === this.rootStore.me.did + } + + setup = bundleAsync(async () => { + try { + if (this.type === 'feed-generator') { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.uri, + }) + this.hydrateFeedGenerator(res.data.view) + } else if (this.type === 'list') { + const res = await this.rootStore.agent.app.bsky.graph.getList({ + list: this.uri, + limit: 1, + }) + this.hydrateList(res.data.list) + } + } catch (e) { + runInAction(() => { + this.error = cleanError(e) + }) + } + }) + + hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) { + this.uri = view.uri + this.cid = view.cid + this.avatar = view.avatar + this.displayName = view.displayName + ? sanitizeDisplayName(view.displayName) + : `Feed by ${sanitizeHandle(view.creator.handle, '@')}` + this.descriptionRT = new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }) + this.creatorDid = view.creator.did + this.creatorHandle = view.creator.handle + this.likeCount = view.likeCount + this.likeUri = view.viewer?.like + this.hasLoaded = true + } + + hydrateList(view: AppBskyGraphDefs.ListView) { + this.uri = view.uri + this.cid = view.cid + this.avatar = view.avatar + this.displayName = view.name + ? sanitizeDisplayName(view.name) + : `User List by ${sanitizeHandle(view.creator.handle, '@')}` + this.descriptionRT = new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }) + this.creatorDid = view.creator.did + this.creatorHandle = view.creator.handle + this.likeCount = undefined + this.hasLoaded = true + } + + async save() { + if (this.type !== 'feed-generator') { + return + } + try { + await this.rootStore.preferences.addSavedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to save feed', error) + } finally { + track('CustomFeed:Save') + } + } + + async unsave() { + if (this.type !== 'feed-generator') { + return + } + try { + await this.rootStore.preferences.removeSavedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to unsave feed', error) + } finally { + track('CustomFeed:Unsave') + } + } + + async pin() { + try { + await this.rootStore.preferences.addPinnedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to pin feed', error) + } finally { + track('CustomFeed:Pin', { + name: this.displayName, + uri: this.uri, + }) + } + } + + async togglePin() { + if (!this.isPinned) { + track('CustomFeed:Pin', { + name: this.displayName, + uri: this.uri, + }) + return this.rootStore.preferences.addPinnedFeed(this.uri) + } else { + track('CustomFeed:Unpin', { + name: this.displayName, + uri: this.uri, + }) + return this.rootStore.preferences.removePinnedFeed(this.uri) + } + } + + async like() { + if (this.type !== 'feed-generator') { + return + } + try { + this.likeUri = 'pending' + this.likeCount = (this.likeCount || 0) + 1 + const res = await this.rootStore.agent.like(this.uri, this.cid) + this.likeUri = res.uri + } catch (e: any) { + this.likeUri = undefined + this.likeCount = (this.likeCount || 1) - 1 + this.rootStore.log.error('Failed to like feed', e) + } finally { + track('CustomFeed:Like') + } + } + + async unlike() { + if (this.type !== 'feed-generator') { + return + } + if (!this.likeUri) { + return + } + const uri = this.likeUri + try { + this.likeUri = undefined + this.likeCount = (this.likeCount || 1) - 1 + await this.rootStore.agent.deleteLike(uri!) + } catch (e: any) { + this.likeUri = uri + this.likeCount = (this.likeCount || 0) + 1 + this.rootStore.log.error('Failed to unlike feed', e) + } finally { + track('CustomFeed:Unlike') + } + } +} diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts index 20d9b60af..135d34dd5 100644 --- a/src/state/models/content/list-membership.ts +++ b/src/state/models/content/list-membership.ts @@ -110,14 +110,21 @@ export class ListMembershipModel { }) } - async updateTo(uris: string[]) { + 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 index fd5074d8c..0331f58bd 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -1,10 +1,12 @@ 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' @@ -13,6 +15,7 @@ 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' const PAGE_SIZE = 30 @@ -37,19 +40,32 @@ export class ListModel { loadMoreCursor?: string // data - list: GraphDefs.ListView | null = null + data: GraphDefs.ListView | null = null items: GraphDefs.ListItemView[] = [] + descriptionRT: RichText | null = null - static async createModList( + static async createList( rootStore: RootStoreModel, { + purpose, name, description, avatar, - }: {name: string; description: string; avatar: RNImage | null | undefined}, + }: { + 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: 'app.bsky.graph.defs#modlist', + purpose, name, description, avatar: undefined, @@ -69,7 +85,20 @@ export class ListModel { }, record, ) - await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) + + // 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 } @@ -95,16 +124,40 @@ export class ListModel { 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.list?.creator.did === this.rootStore.me.did + return this.data?.creator.did === this.rootStore.me.did + } + + get isBlocking() { + return !!this.data?.viewer?.blocked } - get isSubscribed() { - return this.list?.viewer?.muted + get isMuting() { + return !!this.data?.viewer?.muted + } + + get isPinned() { + return this.rootStore.preferences.isPinnedFeed(this.uri) } get creatorDid() { - return this.list?.creator.did + 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 @@ -137,6 +190,15 @@ export class ListModel { } }) + async loadAll() { + for (let i = 0; i < 1000; i++) { + if (!this.hasMore) { + break + } + await this.loadMore() + } + } + async updateMetadata({ name, description, @@ -146,7 +208,7 @@ export class ListModel { description: string avatar: RNImage | null | undefined }) { - if (!this.list) { + if (!this.data) { return } if (!this.isOwner) { @@ -183,7 +245,7 @@ export class ListModel { } async delete() { - if (!this.list) { + if (!this.data) { return } await this._resolveUri() @@ -231,28 +293,140 @@ export class ListModel { this.rootStore.emitListDeleted(this.uri) } - async subscribe() { - if (!this.list) { + 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) { + this.rootStore.log.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, + }) + // 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.app.bsky.graph.muteActorList({ - list: this.list.uri, + 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}} + } }) - track('Lists:Subscribe') - await this.refresh() } - async unsubscribe() { - if (!this.list) { + async block() { + if (!this.data) { return } await this._resolveUri() - await this.rootStore.agent.app.bsky.graph.unmuteActorList({ - list: this.list.uri, + 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}} + } }) - track('Lists:Unsubscribe') - await this.refresh() } /** @@ -314,9 +488,17 @@ export class ListModel { _appendAll(res: GetList.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.list = res.data.list + 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 906f84c28..5333e7116 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -22,7 +22,8 @@ export class ProfileViewerModel { following?: string followedBy?: string blockedBy?: boolean - blocking?: string; + blocking?: string + blockingByList?: AppBskyGraphDefs.ListViewBasic; [key: string]: unknown constructor() { |