New onboarding (#241)

* delete old onboarding files and code

* add custom FollowButton component to Post, FeedItem, & ProfileCard

* move building suggested feed into helper lib

* show suggested posts/feed if follower list is empty

* Update tsconfig.json

* add pagination to getting new onboarding

* remove unnecessary console log

* fix naming, add better null check for combinedCursor

* In locally-combined feeds, correctly produce an undefined cursor when out of data

* Minor refactors of the suggested posts lib functions

* Show 'follow button' style of post meta in certain conditions only

* Only show follow btn in posts on the main feed and the discovery feed

* Add a welcome notice to the home feed

* Tune the timing of when the welcome banner shows or hides

* Make the follow button an observer (closes #244)

* Update postmeta to keep the follow btn after press until next render

* A couple of fixes that ensure consistent welcome screen

* Fix lint

* Rework the welcome banner

* Fix cache invalidation of follows model on user switch

* Show welcome banner while loading

* Update the home onboarding feed to get top posts from hardcode recommends

* Drop unused helper function

* Update happy path tests

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Ansh 2023-03-02 10:21:33 -08:00 committed by GitHub
parent 9b46b2e6a9
commit bd9386d81c
31 changed files with 426 additions and 866 deletions

View file

@ -15,6 +15,12 @@ import {RootStoreModel} from './root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {RichText} from 'lib/strings/rich-text'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
import {
getCombinedCursors,
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
const PAGE_SIZE = 30
@ -535,11 +541,31 @@ export class FeedModel {
}
}
protected _getFeed(
protected async _getFeed(
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
params = Object.assign({}, this.params, params)
if (this.feedType === 'home') {
await this.rootStore.me.follows.fetchIfNeeded()
if (this.rootStore.me.follows.isEmpty) {
const responses = await getMultipleAuthorsPosts(
this.rootStore,
SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)),
params.before,
20,
)
const combinedCursor = getCombinedCursors(responses)
const finalData = mergePosts(responses, {bestOfOnly: true})
const lastHeaders = responses[responses.length - 1].headers
return {
success: true,
data: {
feed: finalData,
cursor: combinedCursor,
},
headers: lastHeaders,
}
}
return this.rootStore.api.app.bsky.feed.getTimeline(
params as GetTimeline.QueryParams,
)

View file

@ -96,6 +96,7 @@ export class MeModel {
this.avatar = ''
}
})
this.mainFeed.clear()
await Promise.all([
this.mainFeed.setup().catch(e => {
this.rootStore.log.error('Failed to setup main feed model', e)

View file

@ -20,6 +20,7 @@ export class MyFollowsModel {
// data
followDidToRecordMap: Record<string, string> = {}
lastSync = 0
myDid?: string
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@ -36,6 +37,7 @@ export class MyFollowsModel {
fetchIfNeeded = bundleAsync(async () => {
if (
this.myDid !== this.rootStore.me.did ||
Object.keys(this.followDidToRecordMap).length === 0 ||
Date.now() - this.lastSync > CACHE_TTL
) {
@ -62,6 +64,7 @@ export class MyFollowsModel {
this.followDidToRecordMap[record.value.subject.did] = record.uri
}
this.lastSync = Date.now()
this.myDid = this.rootStore.me.did
})
})
@ -69,6 +72,10 @@ export class MyFollowsModel {
return !!this.followDidToRecordMap[did]
}
get isEmpty() {
return Object.keys(this.followDidToRecordMap).length === 0
}
getFollowUri(did: string): string {
const v = this.followDidToRecordMap[did]
if (!v) {

View file

@ -1,65 +0,0 @@
import {makeAutoObservable} from 'mobx'
import {isObj, hasProp} from 'lib/type-guards'
export const OnboardStage = {
Explainers: 'explainers',
Follows: 'follows',
}
export const OnboardStageOrder = [OnboardStage.Explainers, OnboardStage.Follows]
export class OnboardModel {
isOnboarding: boolean = false
stage: string = OnboardStageOrder[0]
constructor() {
makeAutoObservable(this, {
serialize: false,
hydrate: false,
})
}
serialize(): unknown {
return {
isOnboarding: this.isOnboarding,
stage: this.stage,
}
}
hydrate(v: unknown) {
if (isObj(v)) {
if (hasProp(v, 'isOnboarding') && typeof v.isOnboarding === 'boolean') {
this.isOnboarding = v.isOnboarding
}
if (
hasProp(v, 'stage') &&
typeof v.stage === 'string' &&
OnboardStageOrder.includes(v.stage)
) {
this.stage = v.stage
}
}
}
start() {
this.isOnboarding = true
}
stop() {
this.isOnboarding = false
}
next() {
if (!this.isOnboarding) {
return
}
let i = OnboardStageOrder.indexOf(this.stage)
i++
if (i >= OnboardStageOrder.length) {
this.isOnboarding = false
this.stage = OnboardStageOrder[0] // in case they make a new account
} else {
this.stage = OnboardStageOrder[i]
}
}
}

View file

@ -17,7 +17,6 @@ import {ProfilesViewModel} from './profiles-view'
import {LinkMetasViewModel} from './link-metas-view'
import {NotificationsViewItemModel} from './notifications-view'
import {MeModel} from './me'
import {OnboardModel} from './onboard'
export const appInfo = z.object({
build: z.string(),
@ -35,7 +34,6 @@ export class RootStoreModel {
nav = new NavigationModel(this)
shell = new ShellUiModel(this)
me = new MeModel(this)
onboard = new OnboardModel()
profiles = new ProfilesViewModel(this)
linkMetas = new LinkMetasViewModel(this)
@ -85,7 +83,6 @@ export class RootStoreModel {
session: this.session.serialize(),
me: this.me.serialize(),
nav: this.nav.serialize(),
onboard: this.onboard.serialize(),
shell: this.shell.serialize(),
}
}
@ -107,9 +104,6 @@ export class RootStoreModel {
if (hasProp(v, 'nav')) {
this.nav.hydrate(v.nav)
}
if (hasProp(v, 'onboard')) {
this.onboard.hydrate(v.onboard)
}
if (hasProp(v, 'session')) {
this.session.hydrate(v.session)
}

View file

@ -345,7 +345,6 @@ export class SessionModel {
)
this.setActiveSession(agent, did)
this.rootStore.onboard.start()
this.rootStore.log.debug('SessionModel:createAccount succeeded')
}

View file

@ -4,26 +4,12 @@ import shuffle from 'lodash.shuffle'
import {RootStoreModel} from './root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {
DEV_SUGGESTED_FOLLOWS,
PROD_SUGGESTED_FOLLOWS,
STAGING_SUGGESTED_FOLLOWS,
} from 'lib/constants'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
const PAGE_SIZE = 30
export type SuggestedActor = Profile.ViewBasic | Profile.View
const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
if (serviceUrl.includes('localhost')) {
return DEV_SUGGESTED_FOLLOWS
} else if (serviceUrl.includes('staging')) {
return STAGING_SUGGESTED_FOLLOWS
} else {
return PROD_SUGGESTED_FOLLOWS
}
}
export class SuggestedActorsViewModel {
// state
pageSize = PAGE_SIZE
@ -126,9 +112,9 @@ export class SuggestedActorsViewModel {
try {
// clone the array so we can mutate it
const actors = [
...getSuggestionList({
serviceUrl: this.rootStore.session.currentSession?.service || '',
}),
...SUGGESTED_FOLLOWS(
this.rootStore.session.currentSession?.service || '',
),
]
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)

View file

@ -1,21 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedFeedViewPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
} from '@atproto/api'
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
import {RootStoreModel} from './root-store'
import {FeedItemModel} from './feed-view'
import {cleanError} from 'lib/strings/errors'
const TEAM_HANDLES = [
'jay.bsky.social',
'paul.bsky.social',
'dan.bsky.social',
'divy.bsky.social',
'why.bsky.social',
'iamrosewang.bsky.social',
]
import {TEAM_HANDLES} from 'lib/constants'
import {
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
export class SuggestedPostsView {
// state
@ -54,15 +45,18 @@ export class SuggestedPostsView {
async setup() {
this._xLoading()
try {
const responses = await Promise.all(
TEAM_HANDLES.map(handle =>
this.rootStore.api.app.bsky.feed
.getAuthorFeed({author: handle, limit: 10})
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
),
const responses = await getMultipleAuthorsPosts(
this.rootStore,
TEAM_HANDLES(String(this.rootStore.agent.service)),
)
runInAction(() => {
this.posts = mergeAndFilterResponses(this.rootStore, responses)
const finalPosts = mergePosts(responses, {repostsOnly: true})
// hydrate into models
this.posts = finalPosts.map((post, i) => {
// strip the reasons to hide that these are reposts
delete post.reason
return new FeedItemModel(this.rootStore, `post-${i}`, post)
})
})
this._xIdle()
} catch (e: any) {
@ -90,59 +84,3 @@ export class SuggestedPostsView {
}
}
}
function mergeAndFilterResponses(
store: RootStoreModel,
responses: GetAuthorFeed.Response[],
): FeedItemModel[] {
let posts: AppBskyFeedFeedViewPost.Main[] = []
// merge into one array
for (const res of responses) {
if (res.success) {
posts = posts.concat(res.data.feed)
}
}
// filter down to reposts of other users
const now = Date.now()
const uris = new Set()
posts = posts.filter(p => {
if (isARepostOfSomeoneElse(p) && isRecentEnough(now, p)) {
if (uris.has(p.post.uri)) {
return false
}
uris.add(p.post.uri)
return true
}
return false
})
// sort by index time
posts.sort((a, b) => {
return (
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
)
})
// hydrate into models and strip the reasons to hide that these are reposts
return posts.map((post, i) => {
delete post.reason
return new FeedItemModel(store, `post-${i}`, post)
})
}
function isARepostOfSomeoneElse(post: AppBskyFeedFeedViewPost.Main): boolean {
return (
post.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost' &&
post.post.author.did !== (post.reason as ReasonRepost).by.did
)
}
const THREE_DAYS = 3 * 24 * 60 * 60 * 1000
function isRecentEnough(
now: number,
post: AppBskyFeedFeedViewPost.Main,
): boolean {
return now - Number(new Date(post.post.indexedAt)) < THREE_DAYS
}