diff options
Diffstat (limited to 'src/state/models/discovery')
-rw-r--r-- | src/state/models/discovery/feeds.ts | 148 | ||||
-rw-r--r-- | src/state/models/discovery/foafs.ts | 132 | ||||
-rw-r--r-- | src/state/models/discovery/onboarding.ts | 106 | ||||
-rw-r--r-- | src/state/models/discovery/suggested-actors.ts | 151 | ||||
-rw-r--r-- | src/state/models/discovery/user-autocomplete.ts | 143 |
5 files changed, 0 insertions, 680 deletions
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts deleted file mode 100644 index a7c94e40d..000000000 --- a/src/state/models/discovery/feeds.ts +++ /dev/null @@ -1,148 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {FeedSourceModel} from '../content/feed-source' -import {logger} from '#/logger' - -const DEFAULT_LIMIT = 50 - -export class FeedsDiscoveryModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreCursor: string | undefined = undefined - - // data - feeds: FeedSourceModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasMore() { - if (this.loadMoreCursor) { - return true - } - return false - } - - get hasContent() { - return this.feeds.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - refresh = bundleAsync(async () => { - this._xLoading() - try { - const res = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - }) - this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - this._xLoading() - try { - const res = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - cursor: this.loadMoreCursor, - }) - this._append(res) - } catch (e: any) { - this._xIdle(e) - } - this._xIdle() - }) - - search = async (query: string) => { - this._xLoading(false) - try { - const results = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - query: query, - }) - this._replaceAll(results) - } catch (e: any) { - this._xIdle(e) - } - this._xIdle() - } - - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.feeds = [] - } - - // state transitions - // = - - _xLoading(isRefreshing = true) { - 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) { - logger.error('Failed to fetch popular feeds', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { - // 1. set feeds data to empty array - this.feeds = [] - // 2. call this._append() - this._append(res) - } - - _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { - // 1. push data into feeds array - for (const f of res.data.feeds) { - 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/discovery/foafs.ts b/src/state/models/discovery/foafs.ts deleted file mode 100644 index 4a647dcfe..000000000 --- a/src/state/models/discovery/foafs.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {AppBskyActorDefs} from '@atproto/api' -import {makeAutoObservable, runInAction} from 'mobx' -import sampleSize from 'lodash.samplesize' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' - -export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & { - followers: AppBskyActorDefs.ProfileView[] -} - -export type ProfileViewFollows = AppBskyActorDefs.ProfileView & { - follows: AppBskyActorDefs.ProfileViewBasic[] -} - -export class FoafsModel { - isLoading = false - hasData = false - sources: string[] = [] - foafs: Map<string, ProfileViewFollows> = new Map() // FOAF stands for Friend of a Friend - popular: RefWithInfoAndFollowers[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - } - - get hasContent() { - if (this.popular.length > 0) { - return true - } - for (const foaf of this.foafs.values()) { - if (foaf.follows.length) { - return true - } - } - return false - } - - fetch = bundleAsync(async () => { - try { - this.isLoading = true - - // fetch some of the user's follows - await this.rootStore.me.follows.syncIfNeeded() - - // grab 10 of the users followed by the user - runInAction(() => { - this.sources = sampleSize( - Object.keys(this.rootStore.me.follows.byDid), - 10, - ) - }) - if (this.sources.length === 0) { - return - } - runInAction(() => { - this.foafs.clear() - this.popular.length = 0 - }) - - // fetch their profiles - const profiles = await this.rootStore.agent.getProfiles({ - actors: this.sources, - }) - - // fetch their follows - const results = await Promise.allSettled( - this.sources.map(source => - this.rootStore.agent.getFollows({actor: source}), - ), - ) - - // store the follows and construct a "most followed" set - const popular: RefWithInfoAndFollowers[] = [] - for (let i = 0; i < results.length; i++) { - const res = results[i] - if (res.status === 'fulfilled') { - this.rootStore.me.follows.hydrateMany(res.value.data.follows) - } - const profile = profiles.data.profiles[i] - const source = this.sources[i] - if (res.status === 'fulfilled' && profile) { - // filter out inappropriate suggestions - res.value.data.follows = res.value.data.follows.filter(follow => { - const viewer = follow.viewer - if (viewer) { - if ( - viewer.following || - viewer.muted || - viewer.mutedByList || - viewer.blockedBy || - viewer.blocking - ) { - return false - } - } - if (follow.did === this.rootStore.me.did) { - return false - } - return true - }) - - runInAction(() => { - this.foafs.set(source, { - ...profile, - follows: res.value.data.follows, - }) - }) - for (const follow of res.value.data.follows) { - let item = popular.find(p => p.did === follow.did) - if (!item) { - item = {...follow, followers: []} - popular.push(item) - } - item.followers.push(profile) - } - } - } - - popular.sort((a, b) => b.followers.length - a.followers.length) - runInAction(() => { - this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20) - }) - this.hasData = true - } catch (e) { - console.error('Failed to fetch FOAFs', e) - } finally { - runInAction(() => { - this.isLoading = false - }) - } - }) -} diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts deleted file mode 100644 index 3638e7f0d..000000000 --- a/src/state/models/discovery/onboarding.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {hasProp} from 'lib/type-guards' -import {track} from 'lib/analytics/analytics' -import {SuggestedActorsModel} from './suggested-actors' - -export const OnboardingScreenSteps = { - Welcome: 'Welcome', - RecommendedFeeds: 'RecommendedFeeds', - RecommendedFollows: 'RecommendedFollows', - Home: 'Home', -} as const - -type OnboardingStep = - (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] -const OnboardingStepsArray = Object.values(OnboardingScreenSteps) -export class OnboardingModel { - // state - step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() - - // data - suggestedActors: SuggestedActorsModel - - constructor(public rootStore: RootStoreModel) { - this.suggestedActors = new SuggestedActorsModel(this.rootStore) - makeAutoObservable(this, { - rootStore: false, - hydrate: false, - serialize: false, - }) - } - - serialize(): unknown { - return { - step: this.step, - } - } - - hydrate(v: unknown) { - if (typeof v === 'object' && v !== null) { - if ( - hasProp(v, 'step') && - typeof v.step === 'string' && - OnboardingStepsArray.includes(v.step as OnboardingStep) - ) { - this.step = v.step as OnboardingStep - } - } else { - // if there is no valid state, we'll just reset - this.reset() - } - } - - /** - * Returns the name of the next screen in the onboarding process based on the current step or screen name provided. - * @param {OnboardingStep} [currentScreenName] - * @returns name of next screen in the onboarding process - */ - next(currentScreenName?: OnboardingStep) { - currentScreenName = currentScreenName || this.step - if (currentScreenName === 'Welcome') { - this.step = 'RecommendedFeeds' - return this.step - } else if (this.step === 'RecommendedFeeds') { - this.step = 'RecommendedFollows' - // prefetch recommended follows - this.suggestedActors.loadMore(true) - return this.step - } else if (this.step === 'RecommendedFollows') { - this.finish() - return this.step - } else { - // if we get here, we're in an invalid state, let's just go Home - return 'Home' - } - } - - start() { - this.step = 'Welcome' - track('Onboarding:Begin') - } - - finish() { - this.rootStore.me.mainFeed.refresh() // load the selected content - this.step = 'Home' - track('Onboarding:Complete') - } - - reset() { - this.step = 'Welcome' - track('Onboarding:Reset') - } - - skip() { - this.step = 'Home' - track('Onboarding:Skipped') - } - - get isComplete() { - return this.step === 'Home' - } - - get isActive() { - return !this.isComplete - } -} diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts deleted file mode 100644 index 450786c2f..000000000 --- a/src/state/models/discovery/suggested-actors.ts +++ /dev/null @@ -1,151 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type SuggestedActor = - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileView - -export class SuggestedActorsModel { - // state - pageSize = PAGE_SIZE - isLoading = false - isRefreshing = false - hasLoaded = false - loadMoreCursor: string | undefined = undefined - error = '' - hasMore = false - lastInsertedAtIndex = -1 - - // data - suggestions: SuggestedActor[] = [] - - constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) { - if (opts?.pageSize) { - this.pageSize = opts.pageSize - } - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.suggestions.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (replace) { - this.hasMore = true - this.loadMoreCursor = undefined - } - if (!this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.actor.getSuggestions({ - limit: 25, - cursor: this.loadMoreCursor, - }) - let {actors, cursor} = res.data - actors = actors.filter( - actor => - !moderateProfile(actor, this.rootStore.preferences.moderationOpts) - .account.filter, - ) - this.rootStore.me.follows.hydrateMany(actors) - - runInAction(() => { - if (replace) { - this.suggestions = [] - } - this.loadMoreCursor = cursor - this.hasMore = !!cursor - this.suggestions = this.suggestions.concat( - actors.filter(actor => { - const viewer = actor.viewer - if (viewer) { - if ( - viewer.following || - viewer.muted || - viewer.mutedByList || - viewer.blockedBy || - viewer.blocking - ) { - return false - } - } - if (actor.did === this.rootStore.me.did) { - return false - } - return true - }), - ) - }) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - async insertSuggestionsByActor(actor: string, indexToInsertAt: number) { - // fetch suggestions - const res = - await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({ - actor: actor, - }) - const {suggestions: moreSuggestions} = res.data - this.rootStore.me.follows.hydrateMany(moreSuggestions) - // dedupe - const toInsert = moreSuggestions.filter( - s => !this.suggestions.find(s2 => s2.did === s.did), - ) - // insert - this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert) - // update index - this.lastInsertedAtIndex = indexToInsertAt - } - - // 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) { - logger.error('Failed to fetch suggested actors', {error: err}) - } - } -} diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts deleted file mode 100644 index f28869e83..000000000 --- a/src/state/models/discovery/user-autocomplete.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' -import AwaitLock from 'await-lock' -import {RootStoreModel} from '../root-store' -import {isInvalidHandle} from 'lib/strings/handles' - -type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic - -export class UserAutocompleteModel { - // state - isLoading = false - isActive = false - prefix = '' - lock = new AwaitLock() - - // data - knownHandles: Set<string> = new Set() - _suggestions: ProfileViewBasic[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - knownHandles: false, - }, - {autoBind: true}, - ) - } - - get follows(): ProfileViewBasic[] { - return Object.values(this.rootStore.me.follows.byDid).map(item => ({ - did: item.did, - handle: item.handle, - displayName: item.displayName, - avatar: item.avatar, - })) - } - - get suggestions(): ProfileViewBasic[] { - if (!this.isActive) { - return [] - } - return this._suggestions - } - - // public api - // = - - async setup() { - this.isLoading = true - await this.rootStore.me.follows.syncIfNeeded() - runInAction(() => { - for (const did in this.rootStore.me.follows.byDid) { - const info = this.rootStore.me.follows.byDid[did] - if (!isInvalidHandle(info.handle)) { - this.knownHandles.add(info.handle) - } - } - this.isLoading = false - }) - } - - setActive(v: boolean) { - this.isActive = v - } - - async setPrefix(prefix: string) { - const origPrefix = prefix.trim().toLocaleLowerCase() - this.prefix = origPrefix - await this.lock.acquireAsync() - try { - if (this.prefix) { - if (this.prefix !== origPrefix) { - return // another prefix was set before we got our chance - } - - // reset to follow results - this._computeSuggestions([]) - - // ask backend - const res = await this.rootStore.agent.searchActorsTypeahead({ - term: this.prefix, - limit: 8, - }) - this._computeSuggestions(res.data.actors) - - // update known handles - runInAction(() => { - for (const u of res.data.actors) { - this.knownHandles.add(u.handle) - } - }) - } else { - runInAction(() => { - this._computeSuggestions([]) - }) - } - } finally { - this.lock.release() - } - } - - // internal - // = - - _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) { - if (this.prefix) { - const items: ProfileViewBasic[] = [] - for (const item of this.follows) { - if (prefixMatch(this.prefix, item)) { - items.push(item) - } - if (items.length >= 8) { - break - } - } - for (const item of searchRes) { - if (!items.find(item2 => item2.handle === item.handle)) { - items.push({ - did: item.did, - handle: item.handle, - displayName: item.displayName, - avatar: item.avatar, - }) - } - } - this._suggestions = items - } else { - this._suggestions = this.follows - } - } -} - -function prefixMatch(prefix: string, info: ProfileViewBasic): boolean { - if (info.handle.includes(prefix)) { - return true - } - if (info.displayName?.toLocaleLowerCase().includes(prefix)) { - return true - } - return false -} |