From ff39b072f3110f018edd6797ee642728f0dae259 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 17 Mar 2023 18:01:53 -0500 Subject: [PATCH] Add foaf-based follow discovery --- src/lib/hooks/usePalette.ts | 4 + src/state/models/discovery/foafs.ts | 98 +++++++++++++ src/view/com/discover/SuggestedFollows.tsx | 159 +++++++-------------- src/view/com/profile/ProfileCard.tsx | 62 ++++++-- src/view/com/util/LoadingPlaceholder.tsx | 49 +++++++ src/view/screens/Search.tsx | 93 +++++++++--- 6 files changed, 335 insertions(+), 130 deletions(-) create mode 100644 src/state/models/discovery/foafs.ts diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts index 5b9929c7..7eeb7422 100644 --- a/src/lib/hooks/usePalette.ts +++ b/src/lib/hooks/usePalette.ts @@ -4,6 +4,7 @@ import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext' export interface UsePaletteValue { colors: PaletteColor view: ViewStyle + viewLight: ViewStyle btn: ViewStyle border: ViewStyle borderDark: ViewStyle @@ -20,6 +21,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue { view: { backgroundColor: palette.background, }, + viewLight: { + backgroundColor: palette.backgroundLight, + }, btn: { backgroundColor: palette.backgroundLight, }, diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts new file mode 100644 index 00000000..4a46ae9d --- /dev/null +++ b/src/state/models/discovery/foafs.ts @@ -0,0 +1,98 @@ +import {AppBskyActorProfile, AppBskyActorRef} 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 = AppBskyActorRef.WithInfo & { + followers: AppBskyActorProfile.View[] +} + +export type ProfileViewFollows = AppBskyActorProfile.View & { + follows: AppBskyActorRef.WithInfo[] +} + +export class FoafsModel { + isLoading = false + hasData = false + sources: string[] = [] + foafs: Map = new Map() + popular: RefWithInfoAndFollowers[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this) + } + + fetch = bundleAsync(async () => { + try { + this.isLoading = true + await this.rootStore.me.follows.fetchIfNeeded() + // grab 10 of the users followed by the user + this.sources = sampleSize( + Object.keys(this.rootStore.me.follows.followDidToRecordMap), + 10, + ) + if (this.sources.length === 0) { + return + } + this.foafs.clear() + this.popular.length = 0 + + // fetch their profiles + const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({ + actors: this.sources, + }) + + // fetch their follows + const results = await Promise.allSettled( + this.sources.map(source => + this.rootStore.api.app.bsky.graph.getFollows({user: 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] + const profile = profiles.data.profiles[i] + const source = this.sources[i] + if (res.status === 'fulfilled' && profile) { + // filter out users already followed by the user or that *is* the user + res.value.data.follows = res.value.data.follows.filter(follow => { + return ( + follow.did !== this.rootStore.me.did && + !this.rootStore.me.follows.isFollowing(follow.did) + ) + }) + + 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/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 1e40956c..dd1136a4 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,116 +1,67 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {CenteredView, FlatList} from '../util/Views' -import {observer} from 'mobx-react-lite' -import {ErrorScreen} from '../util/error/ErrorScreen' +import {StyleSheet, View} from 'react-native' +import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api' +import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {useStores} from 'state/index' -import { - SuggestedActorsViewModel, - SuggestedActor, -} from 'state/models/suggested-actors-view' -import {s} from 'lib/styles' +import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' -export const SuggestedFollows = observer( - ({onNoSuggestions}: {onNoSuggestions?: () => void}) => { - const pal = usePalette('default') - const store = useStores() - - const view = React.useMemo( - () => new SuggestedActorsViewModel(store), - [store], - ) - - React.useEffect(() => { - view - .loadMore() - .catch((err: any) => - store.log.error('Failed to fetch suggestions', err), - ) - }, [view, store.log]) - - React.useEffect(() => { - if (!view.isLoading && !view.hasError && !view.hasContent) { - onNoSuggestions?.() - } - }, [view, view.isLoading, view.hasError, view.hasContent, onNoSuggestions]) - - const onRefresh = () => { - view - .refresh() - .catch((err: any) => - store.log.error('Failed to fetch suggestions', err), - ) - } - const onEndReached = () => { - view - .loadMore() - .catch(err => - view?.rootStore.log.error('Failed to load more suggestions', err), - ) - } - - const renderItem = ({item}: {item: SuggestedActor}) => { - return ( - - ) - } - return ( - - {view.hasError ? ( - - - - ) : view.isEmpty ? ( - - ) : ( - - item.did} - refreshing={view.isRefreshing} - onRefresh={onRefresh} - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - ListFooterComponent={() => ( - - {view.isLoading && } - - )} - contentContainerStyle={s.contentContainer} - /> - - )} - - ) - }, -) +export const SuggestedFollows = ({ + title, + suggestions, +}: { + title: string + suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[] +}) => { + const pal = usePalette('default') + return ( + + + {title} + + {suggestions.map(item => ( + + + + ))} + + ) +} const styles = StyleSheet.create({ container: { - height: '100%', + paddingVertical: 10, + paddingHorizontal: 4, }, - suggestionsContainer: { - height: '100%', + heading: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 8, }, - footer: { - height: 200, - paddingTop: 20, + + card: { + borderRadius: 12, + marginBottom: 2, + borderWidth: 1, + }, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, }, }) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 087536c3..ebb42766 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,6 +1,7 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {AppBskyActorProfile} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' @@ -16,6 +17,7 @@ export function ProfileCard({ description, isFollowedBy, noBorder, + followers, renderButton, }: { handle: string @@ -24,17 +26,13 @@ export function ProfileCard({ description?: string isFollowedBy?: boolean noBorder?: boolean + followers?: AppBskyActorProfile.View[] | undefined renderButton?: () => JSX.Element }) { const pal = usePalette('default') return ( ) : undefined} + {followers?.length ? ( + + + Followed by{' '} + {followers.map(f => f.displayName || f.handle).join(', ')} + + {followers.slice(0, 3).map(f => ( + + + + + + ))} + + ) : undefined} ) } @@ -86,6 +103,8 @@ export const ProfileCardWithFollowBtn = observer( avatar, description, isFollowedBy, + noBorder, + followers, }: { did: string declarationCid: string @@ -94,6 +113,8 @@ export const ProfileCardWithFollowBtn = observer( avatar?: string description?: string isFollowedBy?: boolean + noBorder?: boolean + followers?: AppBskyActorProfile.View[] | undefined }) => { const store = useStores() const isMe = store.me.handle === handle @@ -105,6 +126,8 @@ export const ProfileCardWithFollowBtn = observer( avatar={avatar} description={description} isFollowedBy={isFollowedBy} + noBorder={noBorder} + followers={followers} renderButton={ isMe ? undefined @@ -128,8 +151,8 @@ const styles = StyleSheet.create({ alignItems: 'center', }, layoutAvi: { - width: 60, - paddingLeft: 10, + width: 54, + paddingLeft: 4, paddingTop: 8, paddingBottom: 10, }, @@ -164,4 +187,27 @@ const styles = StyleSheet.create({ marginLeft: 6, paddingHorizontal: 14, }, + + followedBy: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingLeft: 54, + paddingRight: 20, + marginBottom: 10, + marginTop: -6, + }, + followedByAviContainer: { + width: 24, + height: 36, + }, + followedByAvi: { + width: 36, + height: 36, + borderRadius: 18, + padding: 2, + }, + followsByDesc: { + flex: 1, + paddingRight: 10, + }, }) diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 9e72640d..2f653ee0 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -128,6 +128,46 @@ export function NotificationFeedLoadingPlaceholder() { ) } +export function ProfileCardLoadingPlaceholder({ + style, +}: { + style?: StyleProp +}) { + const pal = usePalette('default') + return ( + + + + + + + + + ) +} + +export function ProfileCardFeedLoadingPlaceholder() { + return ( + <> + + + + + + + + + + + + + ) +} + const styles = StyleSheet.create({ loadingPlaceholder: { borderRadius: 6, @@ -147,6 +187,15 @@ const styles = StyleSheet.create({ paddingLeft: 46, margin: 1, }, + profileCard: { + flexDirection: 'row', + padding: 10, + margin: 1, + }, + profileCardAvi: { + borderRadius: 20, + marginRight: 10, + }, smallAvatar: { borderRadius: 15, marginRight: 10, diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 19535a16..246aa13f 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Keyboard, + RefreshControl, StyleSheet, TextInput, TouchableOpacity, @@ -13,21 +14,23 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from '../com/util/Views' +import {ScrollView} from 'view/com/util/Views' import { NativeStackScreenProps, SearchTabNavigatorParams, } from 'lib/routes/types' import {observer} from 'mobx-react-lite' -import {UserAvatar} from '../com/util/UserAvatar' -import {Text} from '../com/util/text/Text' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {FoafsModel} from 'state/models/discovery/foafs' import {s} from 'lib/styles' import {MagnifyingGlassIcon} from 'lib/icons' -import {WhoToFollow} from '../com/discover/WhoToFollow' -import {SuggestedPosts} from '../com/discover/SuggestedPosts' -import {ProfileCard} from '../com/profile/ProfileCard' +import {WhoToFollow} from 'view/com/discover/WhoToFollow' +import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' +import {ProfileCard} from 'view/com/profile/ProfileCard' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' @@ -53,6 +56,11 @@ export const SearchScreen = withAuthRequired( () => new UserAutocompleteViewModel(store), [store], ) + const foafsView = React.useMemo( + () => new FoafsModel(store), + [store], + ) + const [refreshing, setRefreshing] = React.useState(false) const onSoftReset = () => { scrollElRef.current?.scrollTo({x: 0, y: 0}) @@ -71,9 +79,12 @@ export const SearchScreen = withAuthRequired( } store.shell.setMinimalShellMode(false) autocompleteView.setup() + if (!foafsView.hasData) { + foafsView.fetch() + } return cleanup - }, [store, autocompleteView, lastRenderTime, setRenderTime]), + }, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]), ) const onPressMenu = () => { @@ -98,15 +109,18 @@ export const SearchScreen = withAuthRequired( autocompleteView.setActive(false) textInput.current?.blur() } + const onRefresh = React.useCallback(async () => { + setRefreshing(true) + try { + await foafsView.fetch() + } finally { + setRefreshing(false) + } + }, [foafsView, setRefreshing]) return ( - + ) : ( - - - + + }> + {foafsView.isLoading ? ( + + ) : foafsView.sources.length ? ( + <> + {foafsView.popular.length > 0 && ( + + + + )} + {foafsView.sources.map((source, i) => { + const item = foafsView.foafs.get(source) + if (!item || item.follows.length === 0) { + return + } + return ( + + + + ) + })} + + ) : ( + + + + )} )} - - + ) }), @@ -235,4 +288,8 @@ const styles = StyleSheet.create({ textAlign: 'center', paddingTop: 10, }, + + suggestions: { + marginBottom: 8, + }, })