diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-17 18:01:53 -0500 |
---|---|---|
committer | Paul Frazee <pfrazee@gmail.com> | 2023-03-17 18:01:53 -0500 |
commit | ff39b072f3110f018edd6797ee642728f0dae259 (patch) | |
tree | 5d2694c7e794ad392a7e878ca0600d8d13976cf3 /src | |
parent | 6af2585f3232fedd1864ff187bae0153967c7f38 (diff) | |
download | voidsky-ff39b072f3110f018edd6797ee642728f0dae259.tar.zst |
Add foaf-based follow discovery
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/hooks/usePalette.ts | 4 | ||||
-rw-r--r-- | src/state/models/discovery/foafs.ts | 98 | ||||
-rw-r--r-- | src/view/com/discover/SuggestedFollows.tsx | 159 | ||||
-rw-r--r-- | src/view/com/profile/ProfileCard.tsx | 62 | ||||
-rw-r--r-- | src/view/com/util/LoadingPlaceholder.tsx | 49 | ||||
-rw-r--r-- | src/view/screens/Search.tsx | 93 |
6 files changed, 335 insertions, 130 deletions
diff --git a/src/lib/hooks/usePalette.ts b/src/lib/hooks/usePalette.ts index 5b9929c7d..7eeb74228 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 000000000..4a46ae9de --- /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<string, ProfileViewFollows> = 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 1e40956ce..dd1136a48 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<SuggestedActorsViewModel>( - () => 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 ( - <ProfileCardWithFollowBtn - key={item.did} - did={item.did} - declarationCid={item.declaration.cid} - handle={item.handle} - displayName={item.displayName} - avatar={item.avatar} - description={item.description} - /> - ) - } - return ( - <View style={styles.container}> - {view.hasError ? ( - <CenteredView> - <ErrorScreen - title="Failed to load suggestions" - message="There was an error while trying to load suggested follows." - details={view.error} - onPressTryAgain={onRefresh} - /> - </CenteredView> - ) : view.isEmpty ? ( - <View /> - ) : ( - <View style={[styles.suggestionsContainer, pal.view]}> - <FlatList - data={view.suggestions} - keyExtractor={item => item.did} - refreshing={view.isRefreshing} - onRefresh={onRefresh} - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - ListFooterComponent={() => ( - <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} - </View> - )} - contentContainerStyle={s.contentContainer} - /> - </View> - )} - </View> - ) - }, -) +export const SuggestedFollows = ({ + title, + suggestions, +}: { + title: string + suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[] +}) => { + const pal = usePalette('default') + return ( + <View style={[styles.container, pal.view]}> + <Text type="title" style={[styles.heading, pal.text]}> + {title} + </Text> + {suggestions.map(item => ( + <View key={item.did} style={[styles.card, pal.view, pal.border]}> + <ProfileCardWithFollowBtn + key={item.did} + did={item.did} + declarationCid={item.declaration.cid} + handle={item.handle} + displayName={item.displayName} + avatar={item.avatar} + noBorder + description="" + followers={ + item.followers + ? (item.followers as AppBskyActorProfile.View[]) + : undefined + } + /> + </View> + ))} + </View> + ) +} const styles = StyleSheet.create({ container: { - height: '100%', + paddingVertical: 10, + paddingHorizontal: 4, + }, + + heading: { + fontWeight: 'bold', + paddingHorizontal: 4, + paddingBottom: 8, }, - suggestionsContainer: { - height: '100%', + card: { + borderRadius: 12, + marginBottom: 2, + borderWidth: 1, }, - footer: { - height: 200, - paddingTop: 20, + + loadMore: { + paddingLeft: 16, + paddingVertical: 12, }, }) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 087536c36..ebb427664 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 ( <Link - style={[ - styles.outer, - pal.view, - pal.border, - noBorder && styles.outerNoBorder, - ]} + style={[styles.outer, pal.border, noBorder && styles.outerNoBorder]} href={`/profile/${handle}`} title={handle} noFeedback @@ -73,6 +71,25 @@ export function ProfileCard({ </Text> </View> ) : undefined} + {followers?.length ? ( + <View style={styles.followedBy}> + <Text + type="sm" + style={[styles.followsByDesc, pal.textLight]} + numberOfLines={2} + lineHeight={1.2}> + Followed by{' '} + {followers.map(f => f.displayName || f.handle).join(', ')} + </Text> + {followers.slice(0, 3).map(f => ( + <View key={f.did} style={styles.followedByAviContainer}> + <View style={[styles.followedByAvi, pal.view]}> + <UserAvatar avatar={f.avatar} size={32} /> + </View> + </View> + ))} + </View> + ) : undefined} </Link> ) } @@ -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 9e72640d2..2f653ee09 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<ViewStyle> +}) { + const pal = usePalette('default') + return ( + <View style={[styles.profileCard, pal.view, style]}> + <LoadingPlaceholder + width={40} + height={40} + style={styles.profileCardAvi} + /> + <View> + <LoadingPlaceholder width={140} height={8} style={[s.mb5]} /> + <LoadingPlaceholder width={120} height={8} style={[s.mb10]} /> + <LoadingPlaceholder width={220} height={8} style={[s.mb5]} /> + </View> + </View> + ) +} + +export function ProfileCardFeedLoadingPlaceholder() { + return ( + <> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + <ProfileCardLoadingPlaceholder /> + </> + ) +} + 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 19535a164..246aa13f5 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<FoafsModel>( + () => 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 ( <TouchableWithoutFeedback onPress={Keyboard.dismiss}> - <ScrollView - ref={scrollElRef} - testID="searchScrollView" - style={[pal.view, styles.container]} - onScroll={onMainScroll} - scrollEventThrottle={100}> + <View style={[pal.view, styles.container]}> <View style={[pal.view, pal.border, styles.header]}> <TouchableOpacity testID="viewHeaderBackOrMenuBtn" @@ -180,14 +194,53 @@ export const SearchScreen = withAuthRequired( </Text> </View> ) : ( - <ScrollView onScroll={Keyboard.dismiss}> - <WhoToFollow key={`wtf-${lastRenderTime}`} /> - <SuggestedPosts key={`sp-${lastRenderTime}`} /> + <ScrollView + ref={scrollElRef} + testID="searchScrollView" + style={pal.view} + onScroll={onMainScroll} + scrollEventThrottle={100} + refreshControl={ + <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> + }> + {foafsView.isLoading ? ( + <ProfileCardFeedLoadingPlaceholder /> + ) : foafsView.sources.length ? ( + <> + {foafsView.popular.length > 0 && ( + <View style={styles.suggestions}> + <SuggestedFollows + title="In your network" + suggestions={foafsView.popular} + /> + </View> + )} + {foafsView.sources.map((source, i) => { + const item = foafsView.foafs.get(source) + if (!item || item.follows.length === 0) { + return <View key={`sf-${item?.did || i}`} /> + } + return ( + <View key={`sf-${item.did}`} style={styles.suggestions}> + <SuggestedFollows + title={`Followed by ${ + item.displayName || item.handle + }`} + suggestions={item.follows.slice(0, 10)} + /> + </View> + ) + })} + </> + ) : ( + <View style={pal.view}> + <WhoToFollow /> + </View> + )} <View style={s.footerSpacer} /> </ScrollView> )} - <View style={s.footerSpacer} /> - </ScrollView> + </View> </TouchableWithoutFeedback> ) }), @@ -235,4 +288,8 @@ const styles = StyleSheet.create({ textAlign: 'center', paddingTop: 10, }, + + suggestions: { + marginBottom: 8, + }, }) |