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
This commit is contained in:
Paul Frazee 2023-03-19 18:53:57 -05:00 committed by GitHub
parent c31ffdac1b
commit 1de724b24b
33 changed files with 1634 additions and 692 deletions

View file

@ -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
})
}
})
}

View file

@ -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,
}
}

View file

@ -33,6 +33,7 @@ export class MeModel {
clear() {
this.mainFeed.clear()
this.notifications.clear()
this.follows.clear()
this.did = ''
this.handle = ''
this.displayName = ''

View file

@ -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 ||

View file

@ -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')
}

View file

@ -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')
}
}
}