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:
parent
9b46b2e6a9
commit
bd9386d81c
31 changed files with 426 additions and 866 deletions
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -345,7 +345,6 @@ export class SessionModel {
|
|||
)
|
||||
|
||||
this.setActiveSession(agent, did)
|
||||
this.rootStore.onboard.start()
|
||||
this.rootStore.log.debug('SessionModel:createAccount succeeded')
|
||||
}
|
||||
|
||||
|
|
|
@ -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`)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue