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:
parent
c31ffdac1b
commit
1de724b24b
33 changed files with 1634 additions and 692 deletions
110
src/state/models/discovery/foafs.ts
Normal file
110
src/state/models/discovery/foafs.ts
Normal 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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export class MeModel {
|
|||
clear() {
|
||||
this.mainFeed.clear()
|
||||
this.notifications.clear()
|
||||
this.follows.clear()
|
||||
this.did = ''
|
||||
this.handle = ''
|
||||
this.displayName = ''
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue