diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-03-19 18:53:57 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-03-19 18:53:57 -0500 |
commit | 1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf (patch) | |
tree | de1b244a976e55818f1181e6bf2b727237aff7c2 /src | |
parent | c31ffdac1b970d8d51c538f931cc64a942670740 (diff) | |
download | voidsky-1de724b24b9607d4ee83dc0dbb92c13b2b77dcaf.tar.zst |
Add custom feeds selector, rework search, simplify onboarding (#325)
* Get home screen's swipable pager working with the drawer * Add tab bar to pager * Implement popular & following views on home screen * Visual tune-up * Move the feed selector to the footer * Fix to 'new posts' poll * Add the view header as a feed item * Use the native driver on the tabbar indicator to improve perf * Reduce home polling to the currently active page; also reuse some code * Add soft reset on tap selected in tab bar * Remove explicit 'onboarding' flow * Choose good stuff based on service * Add foaf-based follow discovery * Fall back to who to follow * Fix backgrounds * Switch to the off-spec goodstuff route * 1.8 * Fix for dev & staging * Swap the tab bar items and rename suggested to what's hot * Go to whats-hot by default if you have no follows * Implement pager and tabbar for desktop web * Pin deps to make expo happy * Add language filtering to goodstuff
Diffstat (limited to 'src')
27 files changed, 1133 insertions, 363 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index fcd6e787b..ebe6a7cd6 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -4,8 +4,10 @@ import {Linking} from 'react-native' import {RootSiblingParent} from 'react-native-root-siblings' import SplashScreen from 'react-native-splash-screen' import {SafeAreaProvider} from 'react-native-safe-area-context' +import {GestureHandlerRootView} from 'react-native-gesture-handler' import {observer} from 'mobx-react-lite' import {ThemeProvider} from 'lib/ThemeContext' +import {s} from 'lib/styles' import * as view from './view/index' import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from './view/shell' @@ -51,9 +53,11 @@ const App = observer(() => { <RootSiblingParent> <analytics.Provider> <RootStoreProvider value={rootStore}> - <SafeAreaProvider> - <Shell /> - </SafeAreaProvider> + <GestureHandlerRootView style={s.h100pct}> + <SafeAreaProvider> + <Shell /> + </SafeAreaProvider> + </GestureHandlerRootView> </RootStoreProvider> </analytics.Provider> </RootSiblingParent> diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 00938be93..67ca8f952 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -1,5 +1,7 @@ import {AppBskyFeedFeedViewPost} from '@atproto/api' +import lande from 'lande' type FeedViewPost = AppBskyFeedFeedViewPost.Main +import {hasProp} from '@atproto/lexicon' export type FeedTunerFn = ( tuner: FeedTuner, @@ -140,7 +142,8 @@ export class FeedTuner { for (const item of slice.items) { this.seenUris.add(item.post.uri) } - slice.logSelf() + // DEBUG uncomment to get a quick view of the data + // slice.logSelf() } return slices @@ -177,6 +180,33 @@ export class FeedTuner { } } } + + static englishOnly(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { + // TEMP + // remove slices with no english in them + // we very soon need to get the local user's language and filter + // according to their preferences, but for the moment + // we're just rolling with english + // -prf + for (let i = slices.length - 1; i >= 0; i--) { + let hasEnglish = false + for (const item of slices[i].items) { + if ( + hasProp(item.post.record, 'text') && + typeof item.post.record.text === 'string' + ) { + const res = lande(item.post.record.text) + if (res[0][0] === 'eng') { + hasEnglish = true + break + } + } + } + if (!hasEnglish) { + slices.splice(i, 1) + } + } + } } function getSelfReplyUri(item: FeedViewPost): string | undefined { 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/lib/styles.ts b/src/lib/styles.ts index 5d7f7f82d..aa255b21f 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -70,6 +70,7 @@ export const s = StyleSheet.create({ borderRight1: {borderRightWidth: 1}, borderBottom1: {borderBottomWidth: 1}, borderLeft1: {borderLeftWidth: 1}, + hidden: {display: 'none'}, // font weights fw600: {fontWeight: '600'}, diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts new file mode 100644 index 000000000..241338a16 --- /dev/null +++ b/src/state/models/discovery/foafs.ts @@ -0,0 +1,110 @@ +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) + } + + 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 + 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/state/models/feed-view.ts b/src/state/models/feed-view.ts index 42b753b24..c412065dd 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -257,7 +257,7 @@ export class FeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested', + public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, ) { makeAutoObservable( @@ -336,6 +336,20 @@ export class FeedModel { return this.setup() } + private get feedTuners() { + if (this.feedType === 'goodstuff') { + return [ + FeedTuner.dedupReposts, + FeedTuner.likedRepliesOnly, + FeedTuner.englishOnly, + ] + } + if (this.feedType === 'home') { + return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] + } + return [] + } + /** * Load for first render */ @@ -399,6 +413,7 @@ export class FeedModel { params: this.params, e, }) + this.hasMore = false } } finally { this.lock.release() @@ -476,7 +491,8 @@ export class FeedModel { } const res = await this._getFeed({limit: 1}) const currentLatestUri = this.pollCursor - const item = res.data.feed[0] + const slices = this.tuner.tune(res.data.feed, this.feedTuners) + const item = slices[0]?.rootItem if (!item) { return } @@ -541,12 +557,7 @@ export class FeedModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - const slices = this.tuner.tune( - res.data.feed, - this.feedType === 'home' - ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] - : [], - ) + const slices = this.tuner.tune(res.data.feed, this.feedTuners) const toAppend: FeedSliceModel[] = [] for (const slice of slices) { @@ -571,12 +582,7 @@ export class FeedModel { ) { this.pollCursor = res.data.feed[0]?.post.uri - const slices = this.tuner.tune( - res.data.feed, - this.feedType === 'home' - ? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] - : [], - ) + const slices = this.tuner.tune(res.data.feed, this.feedTuners) const toPrepend: FeedSliceModel[] = [] for (const slice of slices) { @@ -634,6 +640,15 @@ export class FeedModel { return this.rootStore.api.app.bsky.feed.getTimeline( params as GetTimeline.QueryParams, ) + } else if (this.feedType === 'goodstuff') { + const res = await getGoodStuff( + this.rootStore.session.currentSession?.accessJwt || '', + params as GetTimeline.QueryParams, + ) + res.data.feed = (res.data.feed || []).filter( + item => !item.post.author.viewer?.muted, + ) + return res } else { return this.rootStore.api.app.bsky.feed.getAuthorFeed( params as GetAuthorFeed.QueryParams, @@ -641,3 +656,45 @@ export class FeedModel { } } } + +// HACK +// temporary off-spec route to get the good stuff +// -prf +async function getGoodStuff( + accessJwt: string, + params: GetTimeline.QueryParams, +): Promise<GetTimeline.Response> { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), 15e3) + + const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') + let k: keyof GetTimeline.QueryParams + for (k in params) { + if (typeof params[k] !== 'undefined') { + uri.searchParams.set(k, String(params[k])) + } + } + + const res = await fetch(String(uri), { + method: 'get', + headers: { + accept: 'application/json', + authorization: `Bearer ${accessJwt}`, + }, + signal: controller.signal, + }) + + const resHeaders: Record<string, string> = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + let resBody = await res.json() + + clearTimeout(to) + + return { + success: res.status === 200, + headers: resHeaders, + data: resBody, + } +} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 077c65595..192e8f19f 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -33,6 +33,7 @@ export class MeModel { clear() { this.mainFeed.clear() this.notifications.clear() + this.follows.clear() this.did = '' this.handle = '' this.displayName = '' diff --git a/src/state/models/my-follows.ts b/src/state/models/my-follows.ts index 732c2fe73..bf1bf9600 100644 --- a/src/state/models/my-follows.ts +++ b/src/state/models/my-follows.ts @@ -35,6 +35,12 @@ export class MyFollowsModel { // public api // = + clear() { + this.followDidToRecordMap = {} + this.lastSync = 0 + this.myDid = undefined + } + fetchIfNeeded = bundleAsync(async () => { if ( this.myDid !== this.rootStore.me.did || diff --git a/src/state/models/session.ts b/src/state/models/session.ts index 306c265d8..e131b2b2c 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -154,13 +154,13 @@ export class SessionModel { /** * Sets the active session */ - setActiveSession(agent: AtpAgent, did: string) { + async setActiveSession(agent: AtpAgent, did: string) { this._log('SessionModel:setActiveSession') this.data = { service: agent.service.toString(), did, } - this.rootStore.handleSessionChange(agent) + await this.rootStore.handleSessionChange(agent) } /** @@ -304,7 +304,7 @@ export class SessionModel { return false } - this.setActiveSession(agent, account.did) + await this.setActiveSession(agent, account.did) return true } @@ -337,7 +337,7 @@ export class SessionModel { }, ) - this.setActiveSession(agent, did) + await this.setActiveSession(agent, did) this._log('SessionModel:login succeeded') } @@ -376,8 +376,7 @@ export class SessionModel { }, ) - this.setActiveSession(agent, did) - this.rootStore.shell.setOnboarding(true) + await this.setActiveSession(agent, did) this._log('SessionModel:createAccount succeeded') } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index d6fefb850..fec1e2899 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -122,13 +122,13 @@ export class ShellUiModel { darkMode = false minimalShellMode = false isDrawerOpen = false + isDrawerSwipeDisabled = false isModalActive = false activeModals: Modal[] = [] isLightboxActive = false activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined isComposerActive = false composerOpts: ComposerOpts | undefined - isOnboarding = false constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, { @@ -168,6 +168,10 @@ export class ShellUiModel { this.isDrawerOpen = false } + setIsDrawerSwipeDisabled(v: boolean) { + this.isDrawerSwipeDisabled = v + } + openModal(modal: Modal) { this.rootStore.emitNavigation() this.isModalActive = true @@ -200,13 +204,4 @@ export class ShellUiModel { this.isComposerActive = false this.composerOpts = undefined } - - setOnboarding(v: boolean) { - this.isOnboarding = v - if (this.isOnboarding) { - this.rootStore.me.mainFeed.switchFeedType('suggested') - } else { - this.rootStore.me.mainFeed.switchFeedType('home') - } - } } diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx index 1e40956ce..7a64a15f6 100644 --- a/src/view/com/discover/SuggestedFollows.tsx +++ b/src/view/com/discover/SuggestedFollows.tsx @@ -1,116 +1,68 @@ 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} + noBg + 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/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1edcd55d9..c910b70e7 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -7,23 +7,17 @@ import { StyleSheet, ViewStyle, } from 'react-native' -import {useNavigation} from '@react-navigation/native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {CenteredView, FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {Text} from '../util/text/Text' +import {ViewHeader} from '../util/ViewHeader' import {ErrorMessage} from '../util/error/ErrorMessage' -import {Button} from '../util/forms/Button' import {FeedModel} from 'state/models/feed-view' import {FeedSlice} from './FeedSlice' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' -import {usePalette} from 'lib/hooks/usePalette' -import {MagnifyingGlassIcon} from 'lib/icons' -import {NavigationProp} from 'lib/routes/types' +const HEADER_ITEM = {_reactKey: '__header__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_FEED_ITEM = {_reactKey: '__error__'} @@ -34,6 +28,7 @@ export const Feed = observer(function Feed({ scrollElRef, onPressTryAgain, onScroll, + renderEmptyState, testID, headerOffset = 0, }: { @@ -43,17 +38,15 @@ export const Feed = observer(function Feed({ scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void onScroll?: OnScrollCb + renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number }) { - const pal = usePalette('default') - const palInverted = usePalette('inverted') const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) - const navigation = useNavigation<NavigationProp>() const data = React.useMemo(() => { - let feedItems: any[] = [] + let feedItems: any[] = [HEADER_ITEM] if (feed.hasLoaded) { if (feed.hasError) { feedItems = feedItems.concat([ERROR_FEED_ITEM]) @@ -80,6 +73,7 @@ export const Feed = observer(function Feed({ } setIsRefreshing(false) }, [feed, track, setIsRefreshing]) + const onEndReached = React.useCallback(async () => { track('Feed:onEndReached') try { @@ -95,37 +89,10 @@ export const Feed = observer(function Feed({ const renderItem = React.useCallback( ({item}: {item: any}) => { if (item === EMPTY_FEED_ITEM) { - return ( - <View style={styles.emptyContainer}> - <View style={styles.emptyIconContainer}> - <MagnifyingGlassIcon - style={[styles.emptyIcon, pal.text]} - size={62} - /> - </View> - <Text type="xl-medium" style={[s.textCenter, pal.text]}> - Your feed is empty! You should follow some accounts to fix this. - </Text> - <Button - type="inverted" - style={styles.emptyBtn} - onPress={ - () => - navigation.navigate( - 'SearchTab', - ) /* TODO make sure it goes to root of the tab */ - }> - <Text type="lg-medium" style={palInverted.text}> - Find accounts - </Text> - <FontAwesomeIcon - icon="angle-right" - style={palInverted.text as FontAwesomeIconStyle} - size={14} - /> - </Button> - </View> - ) + if (renderEmptyState) { + return renderEmptyState() + } + return <View /> } else if (item === ERROR_FEED_ITEM) { return ( <ErrorMessage @@ -133,10 +100,12 @@ export const Feed = observer(function Feed({ onPressTryAgain={onPressTryAgain} /> ) + } else if (item === HEADER_ITEM) { + return <ViewHeader title="Bluesky" canGoBack={false} /> } return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} /> }, - [feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation], + [feed, onPressTryAgain, showPostFollowBtn, renderEmptyState], ) const FeedFooter = React.useCallback( @@ -183,21 +152,4 @@ export const Feed = observer(function Feed({ const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, - emptyContainer: { - paddingVertical: 40, - paddingHorizontal: 30, - }, - emptyIconContainer: { - marginBottom: 16, - }, - emptyIcon: { - marginLeft: 'auto', - marginRight: 'auto', - }, - emptyBtn: { - marginTop: 20, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, }) diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx new file mode 100644 index 000000000..acd035f21 --- /dev/null +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export function FollowingEmptyState() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const navigation = useNavigation<NavigationProp>() + + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) + + return ( + <View style={styles.emptyContainer}> + <View style={styles.emptyIconContainer}> + <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> + </View> + <Text type="xl-medium" style={[s.textCenter, pal.text]}> + Your following feed is empty! Find some accounts to follow to fix this. + </Text> + <Button + type="inverted" + style={styles.emptyBtn} + onPress={onPressFindAccounts}> + <Text type="lg-medium" style={palInverted.text}> + Find accounts to follow + </Text> + <FontAwesomeIcon + icon="angle-right" + style={palInverted.text as FontAwesomeIconStyle} + size={14} + /> + </Button> + </View> + ) +} +const styles = StyleSheet.create({ + emptyContainer: { + // flex: 1, + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + emptyIconContainer: { + marginBottom: 16, + }, + emptyIcon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, + + feedsTip: { + position: 'absolute', + left: 22, + }, + feedsTipArrow: { + marginLeft: 32, + marginTop: 8, + }, +}) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index f24c3d0c9..5204f5a40 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,16 +1,18 @@ import React from 'react' import {observer} from 'mobx-react-lite' -import {Button} from '../util/forms/Button' +import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' import * as apilib from 'lib/api/index' import * as Toast from '../util/Toast' const FollowButton = observer( ({ + type = 'inverted', did, declarationCid, onToggleFollow, }: { + type?: ButtonType did: string declarationCid: string onToggleFollow?: (v: boolean) => void @@ -42,7 +44,7 @@ const FollowButton = observer( return ( <Button - type={isFollowing ? 'default' : 'primary'} + type={isFollowing ? 'default' : type} onPress={onToggleFollowInner} label={isFollowing ? 'Unfollow' : 'Follow'} /> diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 087536c36..53f45fb11 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' @@ -15,7 +16,9 @@ export function ProfileCard({ avatar, description, isFollowedBy, + noBg, noBorder, + followers, renderButton, }: { handle: string @@ -23,7 +26,9 @@ export function ProfileCard({ avatar?: string description?: string isFollowedBy?: boolean + noBg?: boolean noBorder?: boolean + followers?: AppBskyActorProfile.View[] | undefined renderButton?: () => JSX.Element }) { const pal = usePalette('default') @@ -31,9 +36,9 @@ export function ProfileCard({ <Link style={[ styles.outer, - pal.view, pal.border, noBorder && styles.outerNoBorder, + !noBg && pal.view, ]} href={`/profile/${handle}`} title={handle} @@ -73,6 +78,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 +110,9 @@ export const ProfileCardWithFollowBtn = observer( avatar, description, isFollowedBy, + noBg, + noBorder, + followers, }: { did: string declarationCid: string @@ -94,6 +121,9 @@ export const ProfileCardWithFollowBtn = observer( avatar?: string description?: string isFollowedBy?: boolean + noBg?: boolean + noBorder?: boolean + followers?: AppBskyActorProfile.View[] | undefined }) => { const store = useStores() const isMe = store.me.handle === handle @@ -105,6 +135,9 @@ export const ProfileCardWithFollowBtn = observer( avatar={avatar} description={description} isFollowedBy={isFollowedBy} + noBg={noBg} + noBorder={noBorder} + followers={followers} renderButton={ isMe ? undefined @@ -128,8 +161,8 @@ const styles = StyleSheet.create({ alignItems: 'center', }, layoutAvi: { - width: 60, - paddingLeft: 10, + width: 54, + paddingLeft: 4, paddingTop: 8, paddingBottom: 10, }, @@ -164,4 +197,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/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index 0bb402100..c53de5c1f 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -44,7 +44,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { // two-liner with follow button return ( <View style={styles.metaTwoLine}> - <View> + <View style={styles.metaTwoLineLeft}> <View style={styles.metaTwoLineTop}> <DesktopWebTextLink type="lg-bold" @@ -69,6 +69,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { type="md" style={[styles.metaItem, pal.textLight]} lineHeight={1.2} + numberOfLines={1} text={`@${handle}`} href={`/profile/${opts.authorHandle}`} /> @@ -76,6 +77,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { <View> <FollowButton + type="default" did={opts.did} declarationCid={opts.declarationCid} onToggleFollow={onToggleFollow} @@ -134,7 +136,12 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingBottom: 2, + width: '100%', + paddingBottom: 4, + }, + metaTwoLineLeft: { + flex: 1, + paddingRight: 40, }, metaTwoLineTop: { flexDirection: 'row', diff --git a/src/view/com/util/TabBar.tsx b/src/view/com/util/TabBar.tsx new file mode 100644 index 000000000..4b67b8a80 --- /dev/null +++ b/src/view/com/util/TabBar.tsx @@ -0,0 +1,162 @@ +import React, {createRef, useState, useMemo} from 'react' +import { + Animated, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import {Text} from './text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' + +interface Layout { + x: number + width: number +} + +export interface TabBarProps { + selectedPage: number + items: string[] + position: Animated.Value + offset: Animated.Value + indicatorPosition?: 'top' | 'bottom' + indicatorColor?: string + onSelect?: (index: number) => void + onPressSelected?: () => void +} + +export function TabBar({ + selectedPage, + items, + position, + offset, + indicatorPosition = 'bottom', + indicatorColor, + onSelect, + onPressSelected, +}: TabBarProps) { + const pal = usePalette('default') + const [itemLayouts, setItemLayouts] = useState<Layout[]>( + items.map(() => ({x: 0, width: 0})), + ) + const itemRefs = useMemo( + () => Array.from({length: items.length}).map(() => createRef<View>()), + [items.length], + ) + const panX = Animated.add(position, offset) + + const indicatorStyle = { + backgroundColor: indicatorColor || pal.colors.link, + bottom: + indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined, + top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined, + transform: [ + { + translateX: panX.interpolate({ + inputRange: items.map((_item, i) => i), + outputRange: itemLayouts.map(l => l.x + l.width / 2), + }), + }, + { + scaleX: panX.interpolate({ + inputRange: items.map((_item, i) => i), + outputRange: itemLayouts.map(l => l.width), + }), + }, + ], + } + + const onLayout = () => { + const promises = [] + for (let i = 0; i < items.length; i++) { + promises.push( + new Promise<Layout>(resolve => { + itemRefs[i].current?.measure( + (x: number, _y: number, width: number) => { + resolve({x, width}) + }, + ) + }), + ) + } + Promise.all(promises).then((layouts: Layout[]) => { + setItemLayouts(layouts) + }) + } + + const onPressItem = (index: number) => { + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.() + } + } + + return ( + <View style={[pal.view, styles.outer]} onLayout={onLayout}> + <Animated.View style={[styles.indicator, indicatorStyle]} /> + {items.map((item, i) => { + const selected = i === selectedPage + return ( + <TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}> + <View + style={ + indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom + } + ref={itemRefs[i]}> + <Text type="xl-bold" style={selected ? pal.text : pal.textLight}> + {item} + </Text> + </View> + </TouchableWithoutFeedback> + ) + })} + </View> + ) +} + +const styles = isDesktopWeb + ? StyleSheet.create({ + outer: { + flexDirection: 'row', + paddingHorizontal: 18, + }, + itemTop: { + paddingTop: 16, + paddingBottom: 14, + marginRight: 24, + }, + itemBottom: { + paddingTop: 14, + paddingBottom: 16, + marginRight: 24, + }, + indicator: { + position: 'absolute', + left: 0, + width: 1, + height: 3, + }, + }) + : StyleSheet.create({ + outer: { + flexDirection: 'row', + paddingHorizontal: 14, + }, + itemTop: { + paddingTop: 10, + paddingBottom: 10, + marginRight: 24, + }, + itemBottom: { + paddingTop: 8, + paddingBottom: 12, + marginRight: 24, + }, + indicator: { + position: 'absolute', + left: 0, + width: 1, + height: 3, + borderRadius: 4, + }, + }) diff --git a/src/view/com/util/WelcomeBanner.tsx b/src/view/com/util/WelcomeBanner.tsx deleted file mode 100644 index 428a30764..000000000 --- a/src/view/com/util/WelcomeBanner.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from './text/Text' -import {Button} from './forms/Button' -import {s} from 'lib/styles' -import {useStores} from 'state/index' -import {SUGGESTED_FOLLOWS} from 'lib/constants' -// @ts-ignore no type definition -prf -import ProgressBar from 'react-native-progress/Bar' -import {CenteredView} from './Views' - -export const WelcomeBanner = observer(() => { - const pal = usePalette('default') - const store = useStores() - const [isReady, setIsReady] = React.useState(false) - - const numFollows = Math.min( - SUGGESTED_FOLLOWS(String(store.agent.service)).length, - 5, - ) - const remaining = numFollows - store.me.follows.numFollows - - React.useEffect(() => { - if (remaining <= 0) { - // wait 500ms for the progress bar anim to finish - const ti = setTimeout(() => { - setIsReady(true) - }, 500) - return () => clearTimeout(ti) - } else { - setIsReady(false) - } - }, [remaining]) - - const onPressDone = React.useCallback(() => { - store.shell.setOnboarding(false) - }, [store]) - - return ( - <CenteredView - testID="welcomeBanner" - style={[pal.view, styles.container, pal.border]}> - <Text - type="title-lg" - style={[pal.text, s.textCenter, s.bold, s.pb5]} - lineHeight={1.1}> - Welcome to Bluesky! - </Text> - {isReady ? ( - <View style={styles.controls}> - <Button - type="primary" - style={[s.flexRow, s.alignCenter]} - onPress={onPressDone}> - <Text type="md-bold" style={s.white}> - See my feed! - </Text> - <FontAwesomeIcon icon="angle-right" size={14} style={s.white} /> - </Button> - </View> - ) : ( - <> - <Text type="lg" style={[pal.text, s.textCenter]}> - Follow at least {remaining} {remaining === 1 ? 'person' : 'people'}{' '} - to build your feed. - </Text> - <View style={[styles.controls, styles.progress]}> - <ProgressBar - progress={Math.max( - store.me.follows.numFollows / numFollows, - 0.05, - )} - /> - </View> - </> - )} - </CenteredView> - ) -}) - -const styles = StyleSheet.create({ - container: { - paddingTop: 16, - paddingBottom: 16, - paddingHorizontal: 20, - borderTopWidth: 1, - borderBottomWidth: 1, - }, - controls: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - marginTop: 10, - }, - progress: { - marginTop: 12, - }, -}) diff --git a/src/view/com/util/pager/Pager.tsx b/src/view/com/util/pager/Pager.tsx new file mode 100644 index 000000000..416828a27 --- /dev/null +++ b/src/view/com/util/pager/Pager.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import {Animated, View} from 'react-native' +import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {s} from 'lib/styles' + +export type PageSelectedEvent = PagerViewOnPageSelectedEvent +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) + +export interface RenderTabBarFnProps { + selectedPage: number + position: Animated.Value + offset: Animated.Value + onSelect?: (index: number) => void +} +export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element + +interface Props { + tabBarPosition?: 'top' | 'bottom' + initialPage?: number + renderTabBar: RenderTabBarFn + onPageSelected?: (index: number) => void +} +export const Pager = ({ + children, + tabBarPosition = 'top', + initialPage = 0, + renderTabBar, + onPageSelected, +}: React.PropsWithChildren<Props>) => { + const [selectedPage, setSelectedPage] = React.useState(0) + const position = useAnimatedValue(0) + const offset = useAnimatedValue(0) + const pagerView = React.useRef<PagerView>() + + const onPageSelectedInner = React.useCallback( + (e: PageSelectedEvent) => { + setSelectedPage(e.nativeEvent.position) + onPageSelected?.(e.nativeEvent.position) + }, + [setSelectedPage, onPageSelected], + ) + + const onTabBarSelect = React.useCallback( + (index: number) => { + pagerView.current?.setPage(index) + }, + [pagerView], + ) + + return ( + <View> + {tabBarPosition === 'top' && + renderTabBar({ + selectedPage, + position, + offset, + onSelect: onTabBarSelect, + })} + <AnimatedPagerView + ref={pagerView} + style={s.h100pct} + initialPage={initialPage} + onPageSelected={onPageSelectedInner} + onPageScroll={Animated.event( + [ + { + nativeEvent: { + position: position, + offset: offset, + }, + }, + ], + {useNativeDriver: true}, + )}> + {children} + </AnimatedPagerView> + {tabBarPosition === 'bottom' && + renderTabBar({ + selectedPage, + position, + offset, + onSelect: onTabBarSelect, + })} + </View> + ) +} diff --git a/src/view/com/util/pager/Pager.web.tsx b/src/view/com/util/pager/Pager.web.tsx new file mode 100644 index 000000000..3c2805833 --- /dev/null +++ b/src/view/com/util/pager/Pager.web.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import {Animated, View} from 'react-native' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {s} from 'lib/styles' + +export interface RenderTabBarFnProps { + selectedPage: number + position: Animated.Value + offset: Animated.Value + onSelect?: (index: number) => void +} +export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element + +interface Props { + tabBarPosition?: 'top' | 'bottom' + initialPage?: number + renderTabBar: RenderTabBarFn + onPageSelected?: (index: number) => void +} +export const Pager = ({ + children, + tabBarPosition = 'top', + initialPage = 0, + renderTabBar, + onPageSelected, +}: React.PropsWithChildren<Props>) => { + const [selectedPage, setSelectedPage] = React.useState(initialPage) + const position = useAnimatedValue(0) + const offset = useAnimatedValue(0) + + const onTabBarSelect = React.useCallback( + (index: number) => { + setSelectedPage(index) + onPageSelected?.(index) + Animated.timing(position, { + toValue: index, + duration: 200, + useNativeDriver: true, + }).start() + }, + [setSelectedPage, onPageSelected, position], + ) + + return ( + <View> + {tabBarPosition === 'top' && + renderTabBar({ + selectedPage, + position, + offset, + onSelect: onTabBarSelect, + })} + {children.map((child, i) => ( + <View + style={selectedPage === i ? undefined : s.hidden} + key={`page-${i}`}> + {child} + </View> + ))} + {tabBarPosition === 'bottom' && + renderTabBar({ + selectedPage, + position, + offset, + onSelect: onTabBarSelect, + })} + </View> + ) +} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index adc73315c..4950bc0fd 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,26 +1,97 @@ import React from 'react' -import {FlatList, View} from 'react-native' +import {FlatList, View, useWindowDimensions} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' +import {FeedModel} from 'state/models/feed-view' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/posts/Feed' +import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {LoadLatestBtn} from '../com/util/LoadLatestBtn' -import {WelcomeBanner} from '../com/util/WelcomeBanner' +import {FeedsTabBar} from './home/FeedsTabBar' +import {Pager, RenderTabBarFnProps} from 'view/com/util/pager/Pager' import {FAB} from '../com/util/FAB' import {useStores} from 'state/index' import {s} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' +import {isDesktopWeb} from 'platform/detection' -const HEADER_HEIGHT = 42 +const TAB_BAR_HEIGHT = 82 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired( - observer(function Home(_opts: Props) { +export const HomeScreen = withAuthRequired((_opts: Props) => { + const store = useStores() + const [selectedPage, setSelectedPage] = React.useState(0) + + const algoFeed = React.useMemo(() => { + const feed = new FeedModel(store, 'goodstuff', {}) + feed.setup() + return feed + }, [store]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) + return () => { + store.shell.setIsDrawerSwipeDisabled(false) + } + }, [store, selectedPage]), + ) + + const onPageSelected = React.useCallback( + (index: number) => { + setSelectedPage(index) + store.shell.setIsDrawerSwipeDisabled(index > 0) + }, + [store], + ) + + const onPressSelected = React.useCallback(() => { + store.emitScreenSoftReset() + }, [store]) + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return <FeedsTabBar {...props} onPressSelected={onPressSelected} /> + }, + [onPressSelected], + ) + + const renderFollowingEmptyState = React.useCallback(() => { + return <FollowingEmptyState /> + }, []) + + const initialPage = store.me.follows.isEmpty ? 1 : 0 + return ( + <Pager + onPageSelected={onPageSelected} + renderTabBar={renderTabBar} + tabBarPosition={isDesktopWeb ? 'top' : 'bottom'} + initialPage={initialPage}> + <FeedPage + key="1" + isPageFocused={selectedPage === 0} + feed={store.me.mainFeed} + renderEmptyState={renderFollowingEmptyState} + /> + <FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} /> + </Pager> + ) +}) + +const FeedPage = observer( + ({ + isPageFocused, + feed, + renderEmptyState, + }: { + feed: FeedModel + isPageFocused: boolean + renderEmptyState?: () => JSX.Element + }) => { const store = useStores() const onMainScroll = useOnMainScroll(store) const {screen, track} = useAnalytics() @@ -28,38 +99,51 @@ export const HomeScreen = withAuthRequired( const {appState} = useAppState({ onForeground: () => doPoll(true), }) - const isFocused = useIsFocused() + const isScreenFocused = useIsFocused() + const winDim = useWindowDimensions() + const containerStyle = React.useMemo( + () => ({height: winDim.height - (isDesktopWeb ? 0 : TAB_BAR_HEIGHT)}), + [winDim], + ) const doPoll = React.useCallback( (knownActive = false) => { - if ((!knownActive && appState !== 'active') || !isFocused) { + if ( + (!knownActive && appState !== 'active') || + !isScreenFocused || + !isPageFocused + ) { return } - if (store.me.mainFeed.isLoading) { + if (feed.isLoading) { return } store.log.debug('HomeScreen: Polling for new posts') - store.me.mainFeed.checkForLatest() + feed.checkForLatest() }, - [appState, isFocused, store], + [appState, isScreenFocused, isPageFocused, store, feed], ) const scrollToTop = React.useCallback(() => { - // NOTE: the feed is offset by the height of the collapsing header, - // so we scroll to the negative of that height -prf - scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT}) + scrollElRef.current?.scrollToOffset({offset: 0}) }, [scrollElRef]) + const onSoftReset = React.useCallback(() => { + if (isPageFocused) { + scrollToTop() + } + }, [isPageFocused, scrollToTop]) + useFocusEffect( React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(scrollToTop) - const feedCleanup = store.me.mainFeed.registerListeners() + const softResetSub = store.onScreenSoftReset(onSoftReset) + const feedCleanup = feed.registerListeners() const pollInterval = setInterval(doPoll, 15e3) screen('Feed') store.log.debug('HomeScreen: Updating feed') - if (store.me.mainFeed.hasContent) { - store.me.mainFeed.update() + if (feed.hasContent) { + feed.update() } return () => { @@ -67,7 +151,7 @@ export const HomeScreen = withAuthRequired( softResetSub.remove() feedCleanup() } - }, [store, doPoll, scrollToTop, screen]), + }, [store, doPoll, onSoftReset, screen, feed]), ) const onPressCompose = React.useCallback(() => { @@ -76,32 +160,28 @@ export const HomeScreen = withAuthRequired( }, [store, track]) const onPressTryAgain = React.useCallback(() => { - store.me.mainFeed.refresh() - }, [store]) + feed.refresh() + }, [feed]) const onPressLoadLatest = React.useCallback(() => { - store.me.mainFeed.refresh() + feed.refresh() scrollToTop() - }, [store, scrollToTop]) + }, [feed, scrollToTop]) return ( - <View style={s.hContentRegion}> - {store.shell.isOnboarding && <WelcomeBanner />} + <View style={containerStyle}> <Feed testID="homeFeed" key="default" - feed={store.me.mainFeed} + feed={feed} scrollElRef={scrollElRef} style={s.hContentRegion} showPostFollowBtn onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} - headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT} + renderEmptyState={renderEmptyState} /> - {!store.shell.isOnboarding && ( - <ViewHeader title="Bluesky" canGoBack={false} hideOnScroll /> - )} - {store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && ( + {feed.hasNewLatest && !feed.isRefreshing && ( <LoadLatestBtn onPress={onPressLoadLatest} /> )} <FAB @@ -111,5 +191,5 @@ export const HomeScreen = withAuthRequired( /> </View> ) - }), + }, ) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index 19535a164..6ae5fba0d 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.hasContent ? ( + <> + {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, + }, }) diff --git a/src/view/screens/home/FeedsTabBar.tsx b/src/view/screens/home/FeedsTabBar.tsx new file mode 100644 index 000000000..d34034103 --- /dev/null +++ b/src/view/screens/home/FeedsTabBar.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import {Animated, StyleSheet} from 'react-native' +import {observer} from 'mobx-react-lite' +import {TabBar} from 'view/com/util/TabBar' +import {RenderTabBarFnProps} from 'view/com/util/pager/Pager' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {clamp} from 'lodash' + +const BOTTOM_BAR_HEIGHT = 48 + +export const FeedsTabBar = observer( + (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + const store = useStores() + const safeAreaInsets = useSafeAreaInsets() + const pal = usePalette('default') + const interp = useAnimatedValue(0) + + const pad = React.useMemo( + () => ({ + paddingBottom: clamp(safeAreaInsets.bottom, 15, 20), + }), + [safeAreaInsets], + ) + + React.useEffect(() => { + Animated.timing(interp, { + toValue: store.shell.minimalShellMode ? 0 : 1, + duration: 100, + useNativeDriver: true, + isInteraction: false, + }).start() + }, [interp, store.shell.minimalShellMode]) + const transform = { + transform: [ + {translateY: Animated.multiply(interp, -1 * BOTTOM_BAR_HEIGHT)}, + ], + } + + return ( + <Animated.View + style={[pal.view, pal.border, styles.tabBar, pad, transform]}> + <TabBar + {...props} + items={['Following', "What's hot"]} + indicatorPosition="top" + indicatorColor={pal.colors.link} + /> + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + tabBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + borderTopWidth: 1, + paddingTop: 0, + paddingBottom: 30, + }, + tabBarAvi: { + marginRight: 4, + }, +}) diff --git a/src/view/screens/home/FeedsTabBar.web.tsx b/src/view/screens/home/FeedsTabBar.web.tsx new file mode 100644 index 000000000..59ea42988 --- /dev/null +++ b/src/view/screens/home/FeedsTabBar.web.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {TabBar} from 'view/com/util/TabBar' +import {CenteredView} from 'view/com/util/Views' +import {RenderTabBarFnProps} from 'view/com/util/pager/Pager' +import {usePalette} from 'lib/hooks/usePalette' + +export const FeedsTabBar = observer( + (props: RenderTabBarFnProps & {onPressSelected: () => void}) => { + const pal = usePalette('default') + return ( + <CenteredView> + <TabBar + {...props} + items={['Following', "What's hot"]} + indicatorPosition="bottom" + indicatorColor={pal.colors.link} + /> + </CenteredView> + ) + }, +) diff --git a/src/view/shell/BottomBar.tsx b/src/view/shell/BottomBar.tsx index c59d8c675..1cbf2de86 100644 --- a/src/view/shell/BottomBar.tsx +++ b/src/view/shell/BottomBar.tsx @@ -34,16 +34,24 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { const minimalShellInterp = useAnimatedValue(0) const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() - const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState( - state => { - return { + const {isAtHome, isAtSearch, isAtNotifications, noBorder} = + useNavigationState(state => { + const res = { isAtHome: getTabState(state, 'Home') !== TabState.Outside, isAtSearch: getTabState(state, 'Search') !== TabState.Outside, isAtNotifications: getTabState(state, 'Notifications') !== TabState.Outside, + noBorder: getTabState(state, 'Home') === TabState.InsideAtRoot, } - }, - ) + if (!res.isAtHome && !res.isAtNotifications && !res.isAtSearch) { + // HACK for some reason useNavigationState will give us pre-hydration results + // and not update after, so we force isAtHome if all came back false + // -prf + res.isAtHome = true + res.noBorder = true + } + return res + }) React.useEffect(() => { if (store.shell.minimalShellMode) { @@ -99,6 +107,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => { <Animated.View style={[ styles.bottomBar, + noBorder && styles.noBorder, pal.view, pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, @@ -213,6 +222,9 @@ const styles = StyleSheet.create({ paddingLeft: 5, paddingRight: 10, }, + noBorder: { + borderTopWidth: 0, + }, ctrl: { flex: 1, paddingTop: 13, diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index d7877804b..eec0f8ed4 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -46,7 +46,11 @@ const ShellInner = observer(() => { onOpen={onOpenDrawer} onClose={onCloseDrawer} swipeEdgeWidth={winDim.width} - swipeEnabled={!canGoBack && store.session.hasSession}> + swipeEnabled={ + !canGoBack && + store.session.hasSession && + !store.shell.isDrawerSwipeDisabled + }> <TabsNavigator /> </Drawer> </ErrorBoundary> |