diff options
Diffstat (limited to 'src/state/models')
-rw-r--r-- | src/state/models/feeds/algo/actor.ts | 121 | ||||
-rw-r--r-- | src/state/models/feeds/algo/algo-item.ts | 142 | ||||
-rw-r--r-- | src/state/models/feeds/algo/saved.ts | 249 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 31 | ||||
-rw-r--r-- | src/state/models/me.ts | 9 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 25 |
6 files changed, 567 insertions, 10 deletions
diff --git a/src/state/models/feeds/algo/actor.ts b/src/state/models/feeds/algo/actor.ts new file mode 100644 index 000000000..e42df8495 --- /dev/null +++ b/src/state/models/feeds/algo/actor.ts @@ -0,0 +1,121 @@ +import {makeAutoObservable} from 'mobx' +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 {AlgoItemModel} from './algo-item' + +const PAGE_SIZE = 30 + +export class ActorFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: AlgoItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + public params: GetActorFeeds.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ + actor: this.params.actor, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + console.log('res', res.data.feeds) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetActorFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetActorFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new AlgoItemModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/feeds/algo/algo-item.ts b/src/state/models/feeds/algo/algo-item.ts new file mode 100644 index 000000000..bd4ea4fd6 --- /dev/null +++ b/src/state/models/feeds/algo/algo-item.ts @@ -0,0 +1,142 @@ +import {AppBskyFeedDefs, AtUri} from '@atproto/api' +import {makeAutoObservable} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' + +export class AlgoItemModel { + // data + data: AppBskyFeedDefs.GeneratorView + + constructor( + public rootStore: RootStoreModel, + view: AppBskyFeedDefs.GeneratorView, + ) { + this.data = view + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // local actions + // = + set toggleSaved(value: boolean) { + console.log('toggleSaved', this.data.viewer) + if (this.data.viewer) { + this.data.viewer.saved = value + } + } + + get getUri() { + return this.data.uri + } + + get isSaved() { + return this.data.viewer?.saved + } + + get isLiked() { + return this.data.viewer?.like + } + + private toggleLiked(s?: string) { + if (this.data.viewer) { + if (this.data.viewer.like) { + this.data.viewer.like = undefined + } else { + this.data.viewer.like = s + } + } + } + + private incrementLike() { + if (this.data.likeCount) { + this.data.likeCount += 1 + } else { + this.data.likeCount = 1 + } + } + + private decrementLike() { + if (this.data.likeCount) { + this.data.likeCount -= 1 + } else { + this.data.likeCount = 0 + } + } + + private rewriteData(data: AppBskyFeedDefs.GeneratorView) { + this.data = data + } + + // public apis + // = + async like() { + try { + const res = await this.rootStore.agent.app.bsky.feed.like.create( + { + repo: this.rootStore.me.did, + }, + { + subject: { + uri: this.data.uri, + cid: this.data.cid, + }, + createdAt: new Date().toISOString(), + }, + ) + this.toggleLiked(res.uri) + this.incrementLike() + } catch (e: any) { + this.rootStore.log.error('Failed to like feed', e) + } + } + + async unlike() { + try { + await this.rootStore.agent.app.bsky.feed.like.delete({ + repo: this.rootStore.me.did, + rkey: new AtUri(this.data.viewer?.like!).rkey, + }) + this.toggleLiked() + this.decrementLike() + } catch (e: any) { + this.rootStore.log.error('Failed to unlike feed', e) + } + } + + static async getView(store: RootStoreModel, uri: string) { + const res = await store.agent.app.bsky.feed.getFeedGenerator({ + feed: uri, + }) + const view = res.data.view + return view + } + + async checkIsValid() { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.data.uri, + }) + return res.data.isValid + } + + async checkIsOnline() { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.data.uri, + }) + return res.data.isOnline + } + + async reload() { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.data.uri, + }) + this.rewriteData(res.data.view) + } + + serialize() { + return JSON.stringify(this.data) + } +} diff --git a/src/state/models/feeds/algo/saved.ts b/src/state/models/feeds/algo/saved.ts new file mode 100644 index 000000000..cb2015ccb --- /dev/null +++ b/src/state/models/feeds/algo/saved.ts @@ -0,0 +1,249 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyFeedGetSavedFeeds as GetSavedFeeds} from '@atproto/api' +import {RootStoreModel} from '../../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {AlgoItemModel} from './algo-item' +import {hasProp, isObj} from 'lib/type-guards' + +const PAGE_SIZE = 30 + +export class SavedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: AlgoItemModel[] = [] + pinned: AlgoItemModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + serialize() { + return { + pinned: this.pinned.map(f => f.serialize()), + } + } + + hydrate(v: unknown) { + if (isObj(v)) { + if (hasProp(v, 'pinned')) { + const pinnedSerialized = (v as any).pinned as string[] + const pinnedDeserialized = pinnedSerialized.map( + (s: string) => new AlgoItemModel(this.rootStore, JSON.parse(s)), + ) + this.pinned = pinnedDeserialized + } + } + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get numOfFeeds() { + return this.feeds.length + } + + get listOfFeedNames() { + return this.feeds.map( + f => f.data.displayName ?? f.data.creator.displayName + "'s feed", + ) + } + + get listOfPinnedFeedNames() { + return this.pinned.map( + f => f.data.displayName ?? f.data.creator.displayName + "'s feed", + ) + } + + get savedFeedsWithoutPinned() { + return this.feeds.filter( + f => !this.pinned.find(p => p.data.uri === f.data.uri), + ) + } + + togglePinnedFeed(feed: AlgoItemModel) { + if (!this.isPinned(feed)) { + this.pinned.push(feed) + } else { + this.removePinnedFeed(feed.data.uri) + } + } + + removePinnedFeed(uri: string) { + this.pinned = this.pinned.filter(f => f.data.uri !== uri) + } + + reorderPinnedFeeds(temp: AlgoItemModel[]) { + this.pinned = temp + } + + isPinned(feed: AlgoItemModel) { + return this.pinned.find(f => f.data.uri === feed.data.uri) ? true : false + } + + movePinnedItem(item: AlgoItemModel, direction: 'up' | 'down') { + if (this.pinned.length < 2) { + throw new Error('Array must have at least 2 items') + } + const index = this.pinned.indexOf(item) + if (index === -1) { + throw new Error('Item not found in array') + } + + const len = this.pinned.length + + runInAction(() => { + if (direction === 'up') { + if (index === 0) { + // Remove the item from the first place and put it at the end + this.pinned.push(this.pinned.shift()!) + } else { + // Swap the item with the one before it + const temp = this.pinned[index] + this.pinned[index] = this.pinned[index - 1] + this.pinned[index - 1] = temp + } + } else if (direction === 'down') { + if (index === len - 1) { + // Remove the item from the last place and put it at the start + this.pinned.unshift(this.pinned.pop()!) + } else { + // Swap the item with the one after it + const temp = this.pinned[index] + this.pinned[index] = this.pinned[index + 1] + this.pinned[index + 1] = temp + } + } + // this.pinned = [...this.pinned] + }) + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({ + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + removeFeed(uri: string) { + this.feeds = this.feeds.filter(f => f.data.uri !== uri) + } + + addFeed(algoItem: AlgoItemModel) { + this.feeds.push(new AlgoItemModel(this.rootStore, algoItem.data)) + } + + async save(algoItem: AlgoItemModel) { + try { + await this.rootStore.agent.app.bsky.feed.saveFeed({ + feed: algoItem.getUri, + }) + algoItem.toggleSaved = true + this.addFeed(algoItem) + } catch (e: any) { + this.rootStore.log.error('Failed to save feed', e) + } + } + + async unsave(algoItem: AlgoItemModel) { + const uri = algoItem.getUri + try { + await this.rootStore.agent.app.bsky.feed.unsaveFeed({ + feed: uri, + }) + algoItem.toggleSaved = false + this.removeFeed(uri) + this.removePinnedFeed(uri) + } catch (e: any) { + this.rootStore.log.error('Failed to unsanve feed', e) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetSavedFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetSavedFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new AlgoItemModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index b2dffdc69..dfd92b35c 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -4,6 +4,7 @@ import { AppBskyFeedDefs, AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, + AppBskyFeedGetFeed as GetCustomFeed, RichText, jsonToLex, } from '@atproto/api' @@ -309,8 +310,11 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, + public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom', + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, ) { makeAutoObservable( this, @@ -599,13 +603,15 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + async _replaceAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response, + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, replace = false, ) { this.loadMoreCursor = res.data.cursor @@ -644,7 +650,9 @@ export class PostsFeedModel { }) } - _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -661,8 +669,13 @@ export class PostsFeedModel { } protected async _getFeed( - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, - ): Promise<GetTimeline.Response | GetAuthorFeed.Response> { + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, + ): Promise< + GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response + > { params = Object.assign({}, this.params, params) if (this.feedType === 'suggested') { const responses = await getMultipleAuthorsPosts( @@ -684,6 +697,10 @@ export class PostsFeedModel { } } else if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) + } else if (this.feedType === 'custom') { + return this.rootStore.agent.app.bsky.feed.getFeed( + params as GetCustomFeed.QueryParams, + ) } else if (this.feedType === 'goodstuff') { const res = await getGoodStuff( this.rootStore.session.currentSession?.accessJwt || '', diff --git a/src/state/models/me.ts b/src/state/models/me.ts index ba2dc6f32..68c89ac9b 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,6 +8,7 @@ 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 './feeds/algo/saved' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -21,6 +22,7 @@ export class MeModel { followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel + savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -43,12 +45,14 @@ export class MeModel { }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) + this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { this.mainFeed.clear() this.notifications.clear() this.follows.clear() + this.savedFeeds.clear() this.did = '' this.handle = '' this.displayName = '' @@ -65,6 +69,7 @@ export class MeModel { displayName: this.displayName, description: this.description, avatar: this.avatar, + savedFeeds: this.savedFeeds.serialize(), } } @@ -86,6 +91,9 @@ export class MeModel { if (hasProp(v, 'avatar') && typeof v.avatar === 'string') { avatar = v.avatar } + if (hasProp(v, 'savedFeeds') && isObj(v.savedFeeds)) { + this.savedFeeds.hydrate(v.savedFeeds) + } if (did && handle) { this.did = did this.handle = handle @@ -110,6 +118,7 @@ export class MeModel { /* dont await */ this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }) + /* dont await */ this.savedFeeds.refresh() this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() await this.fetchAppPasswords() diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 861b3df0e..4f604bfc0 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -1,18 +1,22 @@ import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' +import {ActorFeedsModel} from '../feeds/algo/actor' import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', + CustomAlgorithms = 'Algos', Lists = 'Lists', } const USER_SELECTOR_ITEMS = [ Sections.Posts, Sections.PostsWithReplies, + Sections.CustomAlgorithms, Sections.Lists, ] @@ -28,6 +32,7 @@ export class ProfileUiModel { // data profile: ProfileModel feed: PostsFeedModel + algos: ActorFeedsModel lists: ListsListModel // ui state @@ -50,10 +55,11 @@ export class ProfileUiModel { actor: params.user, limit: 10, }) + this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ListsListModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies @@ -62,6 +68,9 @@ export class ProfileUiModel { } else if (this.selectedView === Sections.Lists) { return this.lists } + if (this.selectedView === Sections.CustomAlgorithms) { + return this.algos + } throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) } @@ -81,12 +90,17 @@ export class ProfileUiModel { get selectedView() { return this.selectorItems[this.selectedViewIndex] } + isGeneratorView(v: any) { + return AppBskyFeedDefs.isGeneratorView(v) + } get uiItems() { let arr: any[] = [] + // if loading, return loading item to show loading spinner if (this.isInitialLoading) { arr = arr.concat([ProfileUiModel.LOADING_ITEM]) } else if (this.currentView.hasError) { + // if error, return error item to show error message arr = arr.concat([ { _reactKey: '__error__', @@ -94,12 +108,16 @@ export class ProfileUiModel { }, ]) } else { + // not loading, no error, show content if ( this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.CustomAlgorithms ) { if (this.feed.hasContent) { - if (this.selectedView === Sections.Posts) { + if (this.selectedView === Sections.CustomAlgorithms) { + arr = this.algos.feeds + } else if (this.selectedView === Sections.Posts) { arr = this.feed.nonReplyFeed } else { arr = this.feed.slices.slice() @@ -117,6 +135,7 @@ export class ProfileUiModel { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } else { + // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } |