diff options
Diffstat (limited to 'src/state/models/feeds/algo')
-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 |
3 files changed, 512 insertions, 0 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)) + } + } +} |