diff options
Diffstat (limited to 'src/state')
-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 | ||||
-rw-r--r-- | src/state/models/discovery/feeds.ts | 8 | ||||
-rw-r--r-- | src/state/models/feeds/custom-feed.ts | 151 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 28 | ||||
-rw-r--r-- | src/state/models/lists/actor-feeds.ts | 8 | ||||
-rw-r--r-- | src/state/models/lists/lists-list.ts | 205 | ||||
-rw-r--r-- | src/state/models/me.ts | 3 | ||||
-rw-r--r-- | src/state/models/ui/my-feeds.ts | 30 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 25 | ||||
-rw-r--r-- | src/state/models/ui/saved-feeds.ts | 152 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 27 |
14 files changed, 684 insertions, 418 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() { diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts index fa4054ff0..1a00f802c 100644 --- a/src/state/models/discovery/feeds.ts +++ b/src/state/models/discovery/feeds.ts @@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' const DEFAULT_LIMIT = 50 @@ -16,7 +16,7 @@ export class FeedsDiscoveryModel { loadMoreCursor: string | undefined = undefined // data - feeds: CustomFeedModel[] = [] + feeds: FeedSourceModel[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -137,7 +137,9 @@ export class FeedsDiscoveryModel { _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { // 1. push data into feeds array for (const f of res.data.feeds) { - this.feeds.push(new CustomFeedModel(this.rootStore, f)) + const model = new FeedSourceModel(this.rootStore, f.uri) + model.hydrateFeedGenerator(f) + this.feeds.push(model) } // 2. set loadMoreCursor this.loadMoreCursor = res.data.cursor diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts deleted file mode 100644 index 2de4534e7..000000000 --- a/src/state/models/feeds/custom-feed.ts +++ /dev/null @@ -1,151 +0,0 @@ -import {AppBskyFeedDefs} 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 {updateDataOptimistically} from 'lib/async/revertible' -import {track} from 'lib/analytics/analytics' - -export class CustomFeedModel { - // data - _reactKey: string - data: AppBskyFeedDefs.GeneratorView - isOnline: boolean - isValid: boolean - - constructor( - public rootStore: RootStoreModel, - view: AppBskyFeedDefs.GeneratorView, - isOnline?: boolean, - isValid?: boolean, - ) { - this._reactKey = view.uri - this.data = view - this.isOnline = isOnline ?? true - this.isValid = isValid ?? true - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // local actions - // = - - get uri() { - return this.data.uri - } - - get displayName() { - if (this.data.displayName) { - return sanitizeDisplayName(this.data.displayName) - } - return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}` - } - - get isSaved() { - return this.rootStore.preferences.savedFeeds.includes(this.uri) - } - - get isLiked() { - return this.data.viewer?.like - } - - // public apis - // = - - async save() { - try { - await this.rootStore.preferences.addSavedFeed(this.uri) - } catch (error) { - this.rootStore.log.error('Failed to save feed', error) - } finally { - track('CustomFeed:Save') - } - } - - 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.displayName, - uri: this.uri, - }) - } - } - - async unsave() { - try { - await this.rootStore.preferences.removeSavedFeed(this.uri) - } catch (error) { - this.rootStore.log.error('Failed to unsave feed', error) - } finally { - track('CustomFeed:Unsave') - } - } - - async like() { - try { - await updateDataOptimistically( - this.data, - () => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.like = 'pending' - this.data.likeCount = (this.data.likeCount || 0) + 1 - }, - () => this.rootStore.agent.like(this.data.uri, this.data.cid), - res => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.like = res.uri - }, - ) - } catch (e: any) { - this.rootStore.log.error('Failed to like feed', e) - } finally { - track('CustomFeed:Like') - } - } - - async unlike() { - if (!this.data.viewer?.like) { - return - } - try { - const likeUri = this.data.viewer.like - await updateDataOptimistically( - this.data, - () => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.like = undefined - this.data.likeCount = (this.data.likeCount || 1) - 1 - }, - () => this.rootStore.agent.deleteLike(likeUri), - ) - } catch (e: any) { - this.rootStore.log.error('Failed to unlike feed', e) - } finally { - track('CustomFeed:Unlike') - } - } - - async reload() { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ - feed: this.data.uri, - }) - runInAction(() => { - this.data = res.data.view - this.isOnline = res.data.isOnline - this.isValid = res.data.isValid - }) - } - - serialize() { - return JSON.stringify(this.data) - } -} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 2462689b1..169eedac8 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -4,6 +4,7 @@ import { AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetActorLikes as GetActorLikes, + AppBskyFeedGetListFeed as GetListFeed, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -19,6 +20,7 @@ import {FollowingFeedAPI} from 'lib/api/feed/following' import {AuthorFeedAPI} from 'lib/api/feed/author' import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' +import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' const PAGE_SIZE = 30 @@ -36,6 +38,7 @@ type QueryParams = | GetAuthorFeed.QueryParams | GetActorLikes.QueryParams | GetCustomFeed.QueryParams + | GetListFeed.QueryParams export class PostsFeedModel { // state @@ -66,7 +69,13 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', + public feedType: + | 'home' + | 'following' + | 'author' + | 'custom' + | 'likes' + | 'list', params: QueryParams, options?: Options, ) { @@ -99,11 +108,26 @@ export class PostsFeedModel { rootStore, params as GetCustomFeed.QueryParams, ) + } else if (feedType === 'list') { + this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams) } else { this.api = new FollowingFeedAPI(rootStore) } } + get reactKey() { + if (this.feedType === 'author') { + return (this.params as GetAuthorFeed.QueryParams).actor + } + if (this.feedType === 'custom') { + return (this.params as GetCustomFeed.QueryParams).feed + } + if (this.feedType === 'list') { + return (this.params as GetListFeed.QueryParams).list + } + return this.feedType + } + get hasContent() { return this.slices.length !== 0 } @@ -117,7 +141,7 @@ export class PostsFeedModel { } get isLoadingMore() { - return this.isLoading && !this.isRefreshing + return this.isLoading && !this.isRefreshing && this.hasContent } setHasNewLatest(v: boolean) { diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts index 0f2060581..d2bd7680b 100644 --- a/src/state/models/lists/actor-feeds.ts +++ b/src/state/models/lists/actor-feeds.ts @@ -3,7 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' const PAGE_SIZE = 30 @@ -17,7 +17,7 @@ export class ActorFeedsModel { loadMoreCursor?: string // data - feeds: CustomFeedModel[] = [] + feeds: FeedSourceModel[] = [] constructor( public rootStore: RootStoreModel, @@ -114,7 +114,9 @@ export class ActorFeedsModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor for (const f of res.data.feeds) { - this.feeds.push(new CustomFeedModel(this.rootStore, f)) + const model = new FeedSourceModel(this.rootStore, f.uri) + model.hydrateFeedGenerator(f) + this.feeds.push(model) } } } diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts index 54e2f5fde..42638757a 100644 --- a/src/state/models/lists/lists-list.ts +++ b/src/state/models/lists/lists-list.ts @@ -1,12 +1,9 @@ import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetLists as GetLists, - AppBskyGraphGetListMutes as GetListMutes, - AppBskyGraphDefs as GraphDefs, -} from '@atproto/api' +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' const PAGE_SIZE = 30 @@ -25,7 +22,7 @@ export class ListsListModel { constructor( public rootStore: RootStoreModel, - public source: 'my-modlists' | string, + public source: 'mine' | 'my-curatelists' | 'my-modlists' | string, ) { makeAutoObservable( this, @@ -48,6 +45,26 @@ export class ListsListModel { 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. */ @@ -76,44 +93,85 @@ export class ListsListModel { } this._xLoading(replace) try { - let res: GetLists.Response - if (this.source === 'my-modlists') { - res = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - const [res1, res2] = await Promise.all([ - fetchAllUserLists(this.rootStore, this.rootStore.me.did), - fetchAllMyMuteLists(this.rootStore), - ]) - for (let list of res1.data.lists) { - if (list.purpose === 'app.bsky.graph.defs#modlist') { - res.data.lists.push(list) - } + 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, + })), + ), + ) } - for (let list of res2.data.lists) { - if ( - list.purpose === 'app.bsky.graph.defs#modlist' && - !res.data.lists.find(l => l.uri === list.uri) - ) { - res.data.lists.push(list) + 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 { - res = await this.rootStore.agent.app.bsky.graph.getLists({ + 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(res) + this._replaceAll({lists, cursor}) } else { - this._appendAll(res) + this._appendAll({lists, cursor}) } this._xIdle() } catch (e: any) { @@ -156,75 +214,28 @@ export class ListsListModel { // helper functions // = - _replaceAll(res: GetLists.Response | GetListMutes.Response) { + _replaceAll({ + lists, + cursor, + }: { + lists: GraphDefs.ListView[] + cursor: string | undefined + }) { this.lists = [] - this._appendAll(res) + this._appendAll({lists, cursor}) } - _appendAll(res: GetLists.Response | GetListMutes.Response) { - this.loadMoreCursor = res.data.cursor + _appendAll({ + lists, + cursor, + }: { + lists: GraphDefs.ListView[] + cursor: string | undefined + }) { + this.loadMoreCursor = cursor this.hasMore = !!this.loadMoreCursor this.lists = this.lists.concat( - res.data.lists.map(list => ({...list, _reactKey: list.uri})), + lists.map(list => ({...list, _reactKey: list.uri})), ) } } - -async function fetchAllUserLists( - store: RootStoreModel, - did: string, -): Promise<GetLists.Response> { - let acc: GetLists.Response = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - - let cursor - for (let i = 0; i < 100; i++) { - const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({ - actor: did, - cursor, - limit: 50, - }) - cursor = res.data.cursor - acc.data.lists = acc.data.lists.concat(res.data.lists) - if (!cursor) { - break - } - } - - return acc -} - -async function fetchAllMyMuteLists( - store: RootStoreModel, -): Promise<GetListMutes.Response> { - let acc: GetListMutes.Response = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - - let cursor - for (let i = 0; i < 100; i++) { - const res: GetListMutes.Response = - await store.agent.app.bsky.graph.getListMutes({ - cursor, - limit: 50, - }) - cursor = res.data.cursor - acc.data.lists = acc.data.lists.concat(res.data.lists) - if (!cursor) { - break - } - } - - return acc -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 186e61cf6..75c87d765 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts' import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' -import {SavedFeedsModel} from './ui/saved-feeds' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -22,7 +21,6 @@ export class MeModel { followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel - savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -45,7 +43,6 @@ export class MeModel { }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) - this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts index 6b017709e..58f2e7f65 100644 --- a/src/state/models/ui/my-feeds.ts +++ b/src/state/models/ui/my-feeds.ts @@ -1,6 +1,7 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, reaction} from 'mobx' +import {SavedFeedsModel} from './saved-feeds' import {FeedsDiscoveryModel} from '../discovery/feeds' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' import {RootStoreModel} from '../root-store' export type MyFeedsItem = @@ -29,7 +30,7 @@ export type MyFeedsItem = | { _reactKey: string type: 'saved-feed' - feed: CustomFeedModel + feed: FeedSourceModel } | { _reactKey: string @@ -46,21 +47,19 @@ export type MyFeedsItem = | { _reactKey: string type: 'discover-feed' - feed: CustomFeedModel + feed: FeedSourceModel } export class MyFeedsUIModel { + saved: SavedFeedsModel discovery: FeedsDiscoveryModel constructor(public rootStore: RootStoreModel) { makeAutoObservable(this) + this.saved = new SavedFeedsModel(this.rootStore) this.discovery = new FeedsDiscoveryModel(this.rootStore) } - get saved() { - return this.rootStore.me.savedFeeds - } - get isRefreshing() { return !this.saved.isLoading && this.saved.isRefreshing } @@ -78,6 +77,21 @@ export class MyFeedsUIModel { } } + registerListeners() { + const dispose1 = reaction( + () => this.rootStore.preferences.savedFeeds, + () => this.saved.refresh(), + ) + const dispose2 = reaction( + () => this.rootStore.preferences.pinnedFeeds, + () => this.saved.refresh(), + ) + return () => { + dispose1() + dispose2() + } + } + async refresh() { return Promise.all([this.saved.refresh(), this.discovery.refresh()]) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 6ca19b4b7..7714d65df 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -194,7 +194,7 @@ export class PreferencesModel { /** * This function fetches preferences and sets defaults for missing items. */ - async sync({clearCache}: {clearCache?: boolean} = {}) { + async sync() { await this.lock.acquireAsync() try { // fetch preferences @@ -252,8 +252,6 @@ export class PreferencesModel { } finally { this.lock.release() } - - await this.rootStore.me.savedFeeds.updateCache(clearCache) } async syncLegacyPreferences() { @@ -286,6 +284,9 @@ export class PreferencesModel { } } + // languages + // = + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -358,6 +359,9 @@ export class PreferencesModel { return all.join(', ') } + // moderation + // = + async setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, @@ -409,6 +413,13 @@ export class PreferencesModel { } } + // feeds + // = + + isPinnedFeed(uri: string) { + return this.pinnedFeeds.includes(uri) + } + async _optimisticUpdateSavedFeeds( saved: string[], pinned: string[], @@ -474,6 +485,9 @@ export class PreferencesModel { ) } + // other + // = + async setBirthDate(birthDate: Date) { this.birthDate = birthDate await this.lock.acquireAsync() @@ -602,7 +616,7 @@ export class PreferencesModel { } getFeedTuners( - feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', + feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', ) { if (feedType === 'custom') { return [ @@ -610,6 +624,9 @@ export class PreferencesModel { FeedTuner.preferredLangOnly(this.contentLanguages), ] } + if (feedType === 'list') { + return [FeedTuner.dedupReposts] + } if (feedType === 'home' || feedType === 'following') { const feedTuners = [] diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 2dd72980d..4156f792a 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' import {track} from 'lib/analytics/analytics' export class SavedFeedsModel { @@ -13,7 +13,7 @@ export class SavedFeedsModel { error = '' // data - _feedModelCache: Record<string, CustomFeedModel> = {} + all: FeedSourceModel[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -38,20 +38,11 @@ export class SavedFeedsModel { } get pinned() { - return this.rootStore.preferences.pinnedFeeds - .map(uri => this._feedModelCache[uri] as CustomFeedModel) - .filter(Boolean) + return this.all.filter(feed => feed.isPinned) } get unpinned() { - return this.rootStore.preferences.savedFeeds - .filter(uri => !this.isPinned(uri)) - .map(uri => this._feedModelCache[uri] as CustomFeedModel) - .filter(Boolean) - } - - get all() { - return [...this.pinned, ...this.unpinned] + return this.all.filter(feed => !feed.isPinned) } get pinnedFeedNames() { @@ -62,120 +53,38 @@ export class SavedFeedsModel { // = /** - * Syncs the cached models against the current state - * - Should only be called by the preferences model after syncing state - */ - updateCache = bundleAsync(async (clearCache?: boolean) => { - let newFeedModels: Record<string, CustomFeedModel> = {} - if (!clearCache) { - newFeedModels = {...this._feedModelCache} - } - - // collect the feed URIs that havent been synced yet - const neededFeedUris = [] - for (const feedUri of this.rootStore.preferences.savedFeeds) { - if (!(feedUri in newFeedModels)) { - neededFeedUris.push(feedUri) - } - } - - // early exit if no feeds need to be fetched - if (!neededFeedUris.length || neededFeedUris.length === 0) { - return - } - - // fetch the missing models - try { - for (let i = 0; i < neededFeedUris.length; i += 25) { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ - feeds: neededFeedUris.slice(i, 25), - }) - for (const feedInfo of res.data.feeds) { - newFeedModels[feedInfo.uri] = new CustomFeedModel( - this.rootStore, - feedInfo, - ) - } - } - } catch (error) { - console.error('Failed to fetch feed models', error) - this.rootStore.log.error('Failed to fetch feed models', error) - } - - // merge into the cache - runInAction(() => { - this._feedModelCache = newFeedModels - }) - }) - - /** * Refresh the preferences then reload all feed infos */ refresh = bundleAsync(async () => { this._xLoading(true) try { - await this.rootStore.preferences.sync({clearCache: true}) + await this.rootStore.preferences.sync() + const uris = dedup( + this.rootStore.preferences.pinnedFeeds.concat( + this.rootStore.preferences.savedFeeds, + ), + ) + const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri)) + await Promise.all(feeds.map(f => f.setup())) + runInAction(() => { + this.all = feeds + this._updatePinSortOrder() + }) this._xIdle() } catch (e: any) { this._xIdle(e) } }) - async save(feed: CustomFeedModel) { - try { - await feed.save() - await this.updateCache() - } catch (e: any) { - this.rootStore.log.error('Failed to save feed', e) - } - } - - async unsave(feed: CustomFeedModel) { - const uri = feed.uri - try { - if (this.isPinned(feed)) { - await this.rootStore.preferences.removePinnedFeed(uri) - } - await feed.unsave() - } catch (e: any) { - this.rootStore.log.error('Failed to unsave feed', e) - } - } - - async togglePinnedFeed(feed: CustomFeedModel) { - if (!this.isPinned(feed)) { - track('CustomFeed:Pin', { - name: feed.data.displayName, - uri: feed.uri, - }) - return this.rootStore.preferences.addPinnedFeed(feed.uri) - } else { - track('CustomFeed:Unpin', { - name: feed.data.displayName, - uri: feed.uri, - }) - return this.rootStore.preferences.removePinnedFeed(feed.uri) - } - } - - async reorderPinnedFeeds(feeds: CustomFeedModel[]) { - return this.rootStore.preferences.setSavedFeeds( + async reorderPinnedFeeds(feeds: FeedSourceModel[]) { + this._updatePinSortOrder(feeds.map(f => f.uri)) + await this.rootStore.preferences.setSavedFeeds( this.rootStore.preferences.savedFeeds, - feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), + feeds.filter(feed => feed.isPinned).map(feed => feed.uri), ) } - isPinned(feedOrUri: CustomFeedModel | string) { - let uri: string - if (typeof feedOrUri === 'string') { - uri = feedOrUri - } else { - uri = feedOrUri.uri - } - return this.rootStore.preferences.pinnedFeeds.includes(uri) - } - - async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') { + async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { const pinned = this.rootStore.preferences.pinnedFeeds.slice() const index = pinned.indexOf(item.uri) if (index === -1) { @@ -194,8 +103,9 @@ export class SavedFeedsModel { this.rootStore.preferences.savedFeeds, pinned, ) + this._updatePinSortOrder() track('CustomFeed:Reorder', { - name: item.data.displayName, + name: item.displayName, uri: item.uri, index: pinned.indexOf(item.uri), }) @@ -219,4 +129,20 @@ export class SavedFeedsModel { this.rootStore.log.error('Failed to fetch user feeds', err) } } + + // helpers + // = + + _updatePinSortOrder(order?: string[]) { + order ??= this.rootStore.preferences.pinnedFeeds.concat( + this.rootStore.preferences.savedFeeds, + ) + this.all.sort((a, b) => { + return order!.indexOf(a.uri) - order!.indexOf(b.uri) + }) + } +} + +function dedup(strings: string[]): string[] { + return Array.from(new Set(strings)) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index a8937b84c..9c0cc6e30 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,4 @@ -import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' @@ -60,17 +60,25 @@ export type ReportModal = { | {did: string} ) -export interface CreateOrEditMuteListModal { - name: 'create-or-edit-mute-list' +export interface CreateOrEditListModal { + name: 'create-or-edit-list' + purpose?: string list?: ListModel onSave?: (uri: string) => void } -export interface ListAddRemoveUserModal { - name: 'list-add-remove-user' +export interface UserAddRemoveListsModal { + name: 'user-add-remove-lists' subject: string displayName: string - onUpdate?: () => void + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export interface ListAddUserModal { + name: 'list-add-user' + list: ListModel + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void } export interface EditImageModal { @@ -180,8 +188,11 @@ export type Modal = // Moderation | ModerationDetailsModal | ReportModal - | CreateOrEditMuteListModal - | ListAddRemoveUserModal + + // Lists + | CreateOrEditListModal + | UserAddRemoveListsModal + | ListAddUserModal // Posts | AltTextImageModal |