From 4f814207bcff3eb441e8bc196d274fc6adef5405 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 21 Mar 2023 19:18:15 -0500 Subject: Merge the suggested actors model with the general suggestion system (#343) --- src/state/models/discovery/suggested-actors.ts | 170 +++++++++++++++++++++++++ src/state/models/suggested-actors-view.ts | 170 ------------------------- src/view/com/discover/SuggestedFollows.tsx | 12 +- src/view/com/discover/WhoToFollow.tsx | 66 ---------- src/view/com/search/Suggestions.tsx | 32 +++-- src/view/screens/Search.tsx | 12 +- src/view/screens/Search.web.tsx | 12 +- 7 files changed, 222 insertions(+), 252 deletions(-) create mode 100644 src/state/models/discovery/suggested-actors.ts delete mode 100644 src/state/models/suggested-actors-view.ts delete mode 100644 src/view/com/discover/WhoToFollow.tsx (limited to 'src') diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts new file mode 100644 index 000000000..cf8e2dd7b --- /dev/null +++ b/src/state/models/discovery/suggested-actors.ts @@ -0,0 +1,170 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyActorProfile as Profile} from '@atproto/api' +import shuffle from 'lodash.shuffle' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' +import {SUGGESTED_FOLLOWS} from 'lib/constants' + +const PAGE_SIZE = 30 + +export type SuggestedActor = Profile.ViewBasic | Profile.View + +export class SuggestedActorsModel { + // state + pageSize = PAGE_SIZE + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + private hardCodedSuggestions: SuggestedActor[] | undefined + + // 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) { + return + } + if (replace) { + this.hardCodedSuggestions = undefined + } + this._xLoading(replace) + try { + let items: SuggestedActor[] = this.suggestions + if (replace) { + items = [] + this.loadMoreCursor = undefined + } + let res + do { + await this.fetchHardcodedSuggestions() + if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) { + // pull from the hard-coded suggestions + const newItems = this.hardCodedSuggestions.splice(0, this.pageSize) + items = items.concat(newItems) + this.hasMore = true + this.loadMoreCursor = undefined + } else { + // pull from the PDS' algo + res = await this.rootStore.api.app.bsky.actor.getSuggestions({ + limit: this.pageSize, + cursor: this.loadMoreCursor, + }) + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + items = items.concat( + res.data.actors.filter( + actor => !items.find(i => i.did === actor.did), + ), + ) + } + } while (items.length < this.pageSize && this.hasMore) + runInAction(() => { + this.suggestions = items + }) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + private async fetchHardcodedSuggestions() { + if (this.hardCodedSuggestions) { + return + } + await this.rootStore.me.follows.fetchIfNeeded() + try { + // clone the array so we can mutate it + const actors = [ + ...SUGGESTED_FOLLOWS( + this.rootStore.session.currentSession?.service || '', + ), + ] + + // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) + let profiles: Profile.View[] = [] + do { + const res = await this.rootStore.api.app.bsky.actor.getProfiles({ + actors: actors.splice(0, 25), + }) + profiles = profiles.concat(res.data.profiles) + } while (actors.length) + + runInAction(() => { + profiles = profiles.filter(profile => { + if (this.rootStore.me.follows.isFollowing(profile.did)) { + return false + } + if (profile.did === this.rootStore.me.did) { + return false + } + return true + }) + this.hardCodedSuggestions = shuffle(profiles) + }) + } catch (e) { + this.rootStore.log.error( + 'Failed to getProfiles() for suggested follows', + {e}, + ) + runInAction(() => { + this.hardCodedSuggestions = [] + }) + } + } + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _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 suggested actors', err) + } + } +} diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts deleted file mode 100644 index 33c73b4e1..000000000 --- a/src/state/models/suggested-actors-view.ts +++ /dev/null @@ -1,170 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorProfile as Profile} from '@atproto/api' -import shuffle from 'lodash.shuffle' -import {RootStoreModel} from './root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {SUGGESTED_FOLLOWS} from 'lib/constants' - -const PAGE_SIZE = 30 - -export type SuggestedActor = Profile.ViewBasic | Profile.View - -export class SuggestedActorsViewModel { - // state - pageSize = PAGE_SIZE - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - private hardCodedSuggestions: SuggestedActor[] | undefined - - // 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) { - return - } - if (replace) { - this.hardCodedSuggestions = undefined - } - this._xLoading(replace) - try { - let items: SuggestedActor[] = this.suggestions - if (replace) { - items = [] - this.loadMoreCursor = undefined - } - let res - do { - await this.fetchHardcodedSuggestions() - if (this.hardCodedSuggestions && this.hardCodedSuggestions.length > 0) { - // pull from the hard-coded suggestions - const newItems = this.hardCodedSuggestions.splice(0, this.pageSize) - items = items.concat(newItems) - this.hasMore = true - this.loadMoreCursor = undefined - } else { - // pull from the PDS' algo - res = await this.rootStore.api.app.bsky.actor.getSuggestions({ - limit: this.pageSize, - cursor: this.loadMoreCursor, - }) - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - items = items.concat( - res.data.actors.filter( - actor => !items.find(i => i.did === actor.did), - ), - ) - } - } while (items.length < this.pageSize && this.hasMore) - runInAction(() => { - this.suggestions = items - }) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - private async fetchHardcodedSuggestions() { - if (this.hardCodedSuggestions) { - return - } - await this.rootStore.me.follows.fetchIfNeeded() - try { - // clone the array so we can mutate it - const actors = [ - ...SUGGESTED_FOLLOWS( - this.rootStore.session.currentSession?.service || '', - ), - ] - - // fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`) - let profiles: Profile.View[] = [] - do { - const res = await this.rootStore.api.app.bsky.actor.getProfiles({ - actors: actors.splice(0, 25), - }) - profiles = profiles.concat(res.data.profiles) - } while (actors.length) - - runInAction(() => { - profiles = profiles.filter(profile => { - if (this.rootStore.me.follows.isFollowing(profile.did)) { - return false - } - if (profile.did === this.rootStore.me.did) { - return false - } - return true - }) - this.hardCodedSuggestions = shuffle(profiles) - }) - } catch (e) { - this.rootStore.log.error( - 'Failed to getProfiles() for suggested follows', - {e}, - ) - runInAction(() => { - this.hardCodedSuggestions = [] - }) - } - } - - // state transitions - // = - - private _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - private _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 suggested actors', err) - } - } -} diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index bce224231..0d09038ba 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -11,7 +11,11 @@ export const SuggestedFollows = ({ suggestions, }: { title: string - suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[] + suggestions: ( + | AppBskyActorRef.WithInfo + | RefWithInfoAndFollowers + | AppBskyActorProfile.View + )[] }) => { const pal = usePalette('default') return ( @@ -30,7 +34,11 @@ export const SuggestedFollows = ({ avatar={item.avatar} noBg noBorder - description="" + description={ + item.description + ? (item as AppBskyActorProfile.View).description + : '' + } followers={ item.followers ? (item.followers as AppBskyActorProfile.View[]) diff --git a/src/view/com/discover/WhoToFollow.tsx b/src/view/com/discover/WhoToFollow.tsx deleted file mode 100644 index 715fadae2..000000000 --- a/src/view/com/discover/WhoToFollow.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {Text} from '../util/text/Text' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' - -export const WhoToFollow = observer(() => { - const pal = usePalette('default') - const store = useStores() - const suggestedActorsView = React.useMemo( - () => new SuggestedActorsViewModel(store, {pageSize: 15}), - [store], - ) - - React.useEffect(() => { - suggestedActorsView.loadMore(true) - }, [store, suggestedActorsView]) - - return ( - <> - {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( - - Who to follow - - )} - {suggestedActorsView.hasContent && ( - <> - - {suggestedActorsView.suggestions.map(item => ( - - ))} - - - )} - {suggestedActorsView.isLoading && ( - - - - )} - - ) -}) - -const styles = StyleSheet.create({ - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingBottom: 8, - }, - - bottomBorder: { - borderBottomWidth: 1, - }, -}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx index 1747036ba..5231faeac 100644 --- a/src/view/com/search/Suggestions.tsx +++ b/src/view/com/search/Suggestions.tsx @@ -2,15 +2,21 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {FoafsModel} from 'state/models/discovery/foafs' -import {WhoToFollow} from 'view/com/discover/WhoToFollow' +import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -export const Suggestions = observer(({foafs}: {foafs: FoafsModel}) => { - if (foafs.isLoading) { - return - } - if (foafs.hasContent) { +export const Suggestions = observer( + ({ + foafs, + suggestedActors, + }: { + foafs: FoafsModel + suggestedActors: SuggestedActorsModel + }) => { + if (foafs.isLoading || suggestedActors.isLoading) { + return + } return ( <> {foafs.popular.length > 0 && ( @@ -21,7 +27,14 @@ export const Suggestions = observer(({foafs}: {foafs: FoafsModel}) => { /> )} - + {suggestedActors.hasContent && ( + + + + )} {foafs.sources.map((source, i) => { const item = foafs.foafs.get(source) if (!item || item.follows.length === 0) { @@ -38,9 +51,8 @@ export const Suggestions = observer(({foafs}: {foafs: FoafsModel}) => { })} ) - } - return -}) + }, +) const styles = StyleSheet.create({ suggestions: { diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 5850915b3..641d144ae 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -19,6 +19,7 @@ import {useStores} from 'state/index' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' +import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' import {HeaderWithInput} from 'view/com/search/HeaderWithInput' import {Suggestions} from 'view/com/search/Suggestions' import {SearchResults} from 'view/com/search/SearchResults' @@ -44,6 +45,10 @@ export const SearchScreen = withAuthRequired( () => new FoafsModel(store), [store], ) + const suggestedActors = React.useMemo( + () => new SuggestedActorsModel(store), + [store], + ) const [searchUIModel, setSearchUIModel] = React.useState< SearchUIModel | undefined >() @@ -65,9 +70,12 @@ export const SearchScreen = withAuthRequired( if (!foafs.hasData) { foafs.fetch() } + if (!suggestedActors.hasLoaded) { + suggestedActors.loadMore(true) + } return cleanup - }, [store, autocompleteView, foafs]), + }, [store, autocompleteView, foafs, suggestedActors]), ) const onChangeQuery = React.useCallback( @@ -163,7 +171,7 @@ export const SearchScreen = withAuthRequired( ) : ( - + )} diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index cb094d952..d12cbc1be 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -2,6 +2,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' +import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ScrollView} from 'view/com/util/Views' import {Suggestions} from 'view/com/search/Suggestions' @@ -24,6 +25,10 @@ export const SearchScreen = withAuthRequired( () => new FoafsModel(store), [store], ) + const suggestedActors = React.useMemo( + () => new SuggestedActorsModel(store), + [store], + ) const searchUIModel = React.useMemo( () => (route.params.q ? new SearchUIModel(store) : undefined), [route.params.q, store], @@ -36,7 +41,10 @@ export const SearchScreen = withAuthRequired( if (!foafs.hasData) { foafs.fetch() } - }, [foafs, searchUIModel, route.params.q]) + if (!suggestedActors.hasLoaded) { + suggestedActors.loadMore(true) + } + }, [foafs, suggestedActors, searchUIModel, route.params.q]) if (searchUIModel) { return @@ -47,7 +55,7 @@ export const SearchScreen = withAuthRequired( testID="searchScrollView" style={[pal.view, styles.container]} scrollEventThrottle={100}> - + ) -- cgit 1.4.1