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>zio/stable
parent
9b46b2e6a9
commit
bd9386d81c
|
@ -1,46 +0,0 @@
|
|||
import {
|
||||
OnboardModel,
|
||||
OnboardStageOrder,
|
||||
} from '../../../src/state/models/onboard'
|
||||
|
||||
describe('OnboardModel', () => {
|
||||
let onboardModel: OnboardModel
|
||||
|
||||
beforeEach(() => {
|
||||
onboardModel = new OnboardModel()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should start/stop correctly', () => {
|
||||
onboardModel.start()
|
||||
expect(onboardModel.isOnboarding).toBe(true)
|
||||
onboardModel.stop()
|
||||
expect(onboardModel.isOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('should call the next method until it has no more stages', () => {
|
||||
onboardModel.start()
|
||||
onboardModel.next()
|
||||
expect(onboardModel.stage).toBe(OnboardStageOrder[1])
|
||||
|
||||
onboardModel.next()
|
||||
expect(onboardModel.isOnboarding).toBe(false)
|
||||
expect(onboardModel.stage).toBe(OnboardStageOrder[0])
|
||||
})
|
||||
|
||||
it('serialize and hydrate', () => {
|
||||
const serialized = onboardModel.serialize()
|
||||
const newModel = new OnboardModel()
|
||||
newModel.hydrate(serialized)
|
||||
expect(newModel).toEqual(onboardModel)
|
||||
|
||||
onboardModel.start()
|
||||
onboardModel.next()
|
||||
const serialized2 = onboardModel.serialize()
|
||||
newModel.hydrate(serialized2)
|
||||
expect(newModel).toEqual(onboardModel)
|
||||
})
|
||||
})
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-env detox/detox */
|
||||
|
||||
describe('Example', () => {
|
||||
describe('Happy paths', () => {
|
||||
async function grantAccessToUserWithValidCredentials(
|
||||
username,
|
||||
{takeScreenshots} = {takeScreenshots: false},
|
||||
|
@ -65,13 +65,6 @@ describe('Example', () => {
|
|||
await element(by.id('registerIs13Input')).tap()
|
||||
await device.takeScreenshot('4- entered account details')
|
||||
await element(by.id('createAccountButton')).tap()
|
||||
await expect(element(by.id('onboardFeatureExplainerSkipBtn'))).toBeVisible()
|
||||
await expect(element(by.id('onboardFeatureExplainerNextBtn'))).toBeVisible()
|
||||
await device.takeScreenshot('5- onboard feature explainer')
|
||||
await element(by.id('onboardFeatureExplainerSkipBtn')).tap()
|
||||
await expect(element(by.id('onboardFollowsSkipBtn'))).toBeVisible()
|
||||
await expect(element(by.id('onboardFollowsNextBtn'))).toBeVisible()
|
||||
await device.takeScreenshot('6- onboard follows recommender')
|
||||
await element(by.id('onboardFollowsSkipBtn')).tap()
|
||||
await expect(element(by.id('welcomeBanner'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
@ -26,9 +26,9 @@ PODS:
|
|||
- libwebp/mux (1.2.4):
|
||||
- libwebp/demux
|
||||
- libwebp/webp (1.2.4)
|
||||
- Permission-Camera (3.6.1):
|
||||
- Permission-Camera (3.7.2):
|
||||
- RNPermissions
|
||||
- Permission-PhotoLibrary (3.6.1):
|
||||
- Permission-PhotoLibrary (3.7.2):
|
||||
- RNPermissions
|
||||
- RCT-Folly (2021.07.22.00):
|
||||
- boost
|
||||
|
@ -359,7 +359,7 @@ PODS:
|
|||
- React-Core
|
||||
- RNCAsyncStorage (1.17.11):
|
||||
- React-Core
|
||||
- RNCClipboard (1.11.1):
|
||||
- RNCClipboard (1.11.2):
|
||||
- React-Core
|
||||
- RNFastImage (8.6.3):
|
||||
- React-Core
|
||||
|
@ -385,7 +385,7 @@ PODS:
|
|||
- RNNotifee/NotifeeCore (= 7.5.0)
|
||||
- RNNotifee/NotifeeCore (7.5.0):
|
||||
- React-Core
|
||||
- RNPermissions (3.6.1):
|
||||
- RNPermissions (3.7.2):
|
||||
- React-Core
|
||||
- RNReactNativeHapticFeedback (1.14.0):
|
||||
- React-Core
|
||||
|
@ -652,8 +652,8 @@ SPEC CHECKSUMS:
|
|||
hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
|
||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
|
||||
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
|
||||
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
|
||||
Permission-Camera: db22e80aa0858a8b6d65979a97f2f481dd8a0ebd
|
||||
Permission-PhotoLibrary: 7d80161682e08042fd8b0bf934ea97a8495e0e6a
|
||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
|
||||
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
|
||||
|
@ -693,14 +693,14 @@ SPEC CHECKSUMS:
|
|||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
||||
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
||||
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
||||
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
||||
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
||||
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
|
||||
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
|
||||
RNPermissions: 2fbbcb7244357507f958d626d58eb15fb0013d85
|
||||
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
||||
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
||||
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
import {RootStoreModel} from 'state/index'
|
||||
import {
|
||||
AppBskyFeedFeedViewPost,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
} from '@atproto/api'
|
||||
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
||||
|
||||
async function getMultipleAuthorsPosts(
|
||||
rootStore: RootStoreModel,
|
||||
authors: string[],
|
||||
cursor: string | undefined = undefined,
|
||||
limit: number = 10,
|
||||
) {
|
||||
const responses = await Promise.all(
|
||||
authors.map((author, index) =>
|
||||
rootStore.api.app.bsky.feed
|
||||
.getAuthorFeed({
|
||||
author,
|
||||
limit,
|
||||
before: cursor ? cursor.split(',')[index] : undefined,
|
||||
})
|
||||
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
|
||||
),
|
||||
)
|
||||
return responses
|
||||
}
|
||||
|
||||
function mergePosts(
|
||||
responses: GetAuthorFeed.Response[],
|
||||
{repostsOnly, bestOfOnly}: {repostsOnly?: boolean; bestOfOnly?: boolean},
|
||||
) {
|
||||
let posts: AppBskyFeedFeedViewPost.Main[] = []
|
||||
|
||||
if (bestOfOnly) {
|
||||
for (const res of responses) {
|
||||
if (res.success) {
|
||||
// filter the feed down to the post with the most upvotes
|
||||
res.data.feed = res.data.feed.reduce(
|
||||
(acc: AppBskyFeedFeedViewPost.Main[], v) => {
|
||||
if (!acc?.[0] && !v.reason) {
|
||||
return [v]
|
||||
}
|
||||
if (
|
||||
acc &&
|
||||
!v.reason &&
|
||||
v.post.upvoteCount > acc[0].post.upvoteCount
|
||||
) {
|
||||
return [v]
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 uris = new Set()
|
||||
posts = posts.filter(p => {
|
||||
if (repostsOnly && !isARepostOfSomeoneElse(p)) {
|
||||
return false
|
||||
}
|
||||
if (uris.has(p.post.uri)) {
|
||||
return false
|
||||
}
|
||||
uris.add(p.post.uri)
|
||||
return true
|
||||
})
|
||||
|
||||
// sort by index time
|
||||
posts.sort((a, b) => {
|
||||
return (
|
||||
Number(new Date(b.post.indexedAt)) - Number(new Date(a.post.indexedAt))
|
||||
)
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
function getCombinedCursors(responses: GetAuthorFeed.Response[]) {
|
||||
let hasCursor = false
|
||||
const cursors = responses.map(r => {
|
||||
if (r.data.cursor) {
|
||||
hasCursor = true
|
||||
return r.data.cursor
|
||||
}
|
||||
return ''
|
||||
})
|
||||
if (!hasCursor) {
|
||||
return undefined
|
||||
}
|
||||
const combinedCursors = cursors.join(',')
|
||||
return combinedCursors
|
||||
}
|
||||
|
||||
function isCombinedCursor(cursor: string) {
|
||||
return cursor.includes(',')
|
||||
}
|
||||
|
||||
export {
|
||||
getMultipleAuthorsPosts,
|
||||
mergePosts,
|
||||
getCombinedCursors,
|
||||
isCombinedCursor,
|
||||
}
|
|
@ -4,6 +4,31 @@ export const FEEDBACK_FORM_URL =
|
|||
export const MAX_DISPLAY_NAME = 64
|
||||
export const MAX_DESCRIPTION = 256
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
'jay.bsky.social',
|
||||
'paul.bsky.social',
|
||||
'dan.bsky.social',
|
||||
'divy.bsky.social',
|
||||
'why.bsky.social',
|
||||
'iamrosewang.bsky.social',
|
||||
]
|
||||
export const STAGING_TEAM_HANDLES = [
|
||||
'arcalinea.staging.bsky.dev',
|
||||
'paul.staging.bsky.dev',
|
||||
'paul2.staging.bsky.dev',
|
||||
]
|
||||
export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
|
||||
|
||||
export function TEAM_HANDLES(serviceUrl: string) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
return DEV_TEAM_HANDLES
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
return STAGING_TEAM_HANDLES
|
||||
} else {
|
||||
return PROD_TEAM_HANDLES
|
||||
}
|
||||
}
|
||||
|
||||
export const PROD_SUGGESTED_FOLLOWS = [
|
||||
'john',
|
||||
'visakanv',
|
||||
|
@ -55,14 +80,21 @@ export const PROD_SUGGESTED_FOLLOWS = [
|
|||
'jay',
|
||||
'paul',
|
||||
].map(handle => `${handle}.bsky.social`)
|
||||
|
||||
export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
|
||||
handle => `${handle}.staging.bsky.dev`,
|
||||
)
|
||||
|
||||
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
|
||||
handle => `${handle}.test`,
|
||||
)
|
||||
export function SUGGESTED_FOLLOWS(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 const POST_IMG_MAX_WIDTH = 2000
|
||||
export const POST_IMG_MAX_HEIGHT = 2000
|
||||
|
|
|
@ -62,6 +62,10 @@ export const s = StyleSheet.create({
|
|||
footerSpacer: {height: 100},
|
||||
contentContainer: {paddingBottom: 200},
|
||||
border1: {borderWidth: 1},
|
||||
borderTop1: {borderTopWidth: 1},
|
||||
borderRight1: {borderRightWidth: 1},
|
||||
borderBottom1: {borderBottomWidth: 1},
|
||||
borderLeft1: {borderLeftWidth: 1},
|
||||
|
||||
// font weights
|
||||
fw600: {fontWeight: '600'},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ export const SuggestedPosts = observer(() => {
|
|||
<>
|
||||
<View style={[pal.border, styles.bottomBorder]}>
|
||||
{suggestedPostsView.posts.map(item => (
|
||||
<Post item={item} key={item._reactKey} />
|
||||
<Post item={item} key={item._reactKey} showFollowBtn />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
|
|
|
@ -1,196 +0,0 @@
|
|||
import React, {useState} from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Image,
|
||||
SafeAreaView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
import {TABS_EXPLAINER} from 'lib/assets'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
|
||||
const ROUTES = TABS_ENABLED
|
||||
? [
|
||||
{key: 'intro', title: 'Intro'},
|
||||
{key: 'tabs', title: 'Tabs'},
|
||||
]
|
||||
: [{key: 'intro', title: 'Intro'}]
|
||||
|
||||
const Intro = () => (
|
||||
<View style={styles.explainer}>
|
||||
<Text
|
||||
style={[styles.explainerHeading, s.normal, styles.explainerHeadingIntro]}>
|
||||
Welcome to{' '}
|
||||
<Text style={[s.bold, s.blue3, styles.explainerHeadingBrand]}>
|
||||
Bluesky
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.explainerDesc, styles.explainerDescIntro]}>
|
||||
This is an early beta. Your feedback is appreciated!
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const Tabs = () => (
|
||||
<View style={styles.explainer}>
|
||||
<View style={styles.explainerIcon}>
|
||||
<View style={s.flex1} />
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'clone']}
|
||||
style={[s.black as FontAwesomeIconStyle, s.mb5]}
|
||||
size={36}
|
||||
/>
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
<Text style={styles.explainerHeading}>Tabs</Text>
|
||||
<Text style={styles.explainerDesc}>
|
||||
Never lose your place! Long-press to open posts and profiles in a new tab.
|
||||
</Text>
|
||||
<Text style={styles.explainerDesc}>
|
||||
<Image source={TABS_EXPLAINER} style={styles.explainerImg} />
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const SCENE_MAP = {
|
||||
intro: Intro,
|
||||
tabs: Tabs,
|
||||
}
|
||||
const renderScene = SceneMap(SCENE_MAP)
|
||||
|
||||
export const FeatureExplainer = () => {
|
||||
const layout = useWindowDimensions()
|
||||
const store = useStores()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
const onPressSkip = () => store.onboard.next()
|
||||
const onPressNext = () => {
|
||||
if (index >= ROUTES.length - 1) {
|
||||
store.onboard.next()
|
||||
} else {
|
||||
setIndex(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabBar = (props: TabBarProps<Route>) => {
|
||||
const inputRange = props.navigationState.routes.map((x, i) => i)
|
||||
return (
|
||||
<View style={styles.tabBar}>
|
||||
<View style={s.flex1} />
|
||||
{props.navigationState.routes.map((route, i) => {
|
||||
const opacity = props.position.interpolate({
|
||||
inputRange,
|
||||
outputRange: inputRange.map(inputIndex =>
|
||||
inputIndex === i ? 1 : 0.5,
|
||||
),
|
||||
})
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={styles.tabItem}
|
||||
onPress={() => setIndex(i)}>
|
||||
<Animated.Text style={{opacity}}>°</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const FirstExplainer = SCENE_MAP[ROUTES[0]?.key as keyof typeof SCENE_MAP]
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{ROUTES.length > 1 ? (
|
||||
<TabView
|
||||
navigationState={{index, routes: ROUTES}}
|
||||
renderScene={renderScene}
|
||||
renderTabBar={renderTabBar}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{width: layout.width}}
|
||||
tabBarPosition="bottom"
|
||||
/>
|
||||
) : FirstExplainer ? (
|
||||
<FirstExplainer />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressSkip}
|
||||
testID="onboardFeatureExplainerSkipBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity
|
||||
onPress={onPressNext}
|
||||
testID="onboardFeatureExplainerNextBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Next</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tabItem: {
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
explainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 80,
|
||||
},
|
||||
explainerIcon: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
explainerHeading: {
|
||||
fontSize: 42,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
explainerHeadingIntro: {
|
||||
lineHeight: 60,
|
||||
paddingTop: 50,
|
||||
paddingBottom: 50,
|
||||
},
|
||||
explainerHeadingBrand: {fontSize: 56},
|
||||
explainerDesc: {
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
explainerDescIntro: {fontSize: 24},
|
||||
explainerImg: {
|
||||
resizeMode: 'contain',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 330,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
})
|
|
@ -1,202 +0,0 @@
|
|||
import React, {useState} from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Image,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {TabView, SceneMap, Route, TabBarProps} from 'react-native-tab-view'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {CenteredView} from '../util/Views.web'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {TABS_EXPLAINER} from 'lib/assets'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
|
||||
const ROUTES = TABS_ENABLED
|
||||
? [
|
||||
{key: 'intro', title: 'Intro'},
|
||||
{key: 'tabs', title: 'Tabs'},
|
||||
]
|
||||
: [{key: 'intro', title: 'Intro'}]
|
||||
|
||||
const Intro = () => (
|
||||
<View style={styles.explainer}>
|
||||
<Text
|
||||
style={[styles.explainerHeading, s.normal, styles.explainerHeadingIntro]}>
|
||||
Welcome to{' '}
|
||||
<Text style={[s.bold, s.blue3, styles.explainerHeadingBrand]}>
|
||||
Bluesky
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.explainerDesc, styles.explainerDescIntro]}>
|
||||
This is an early beta. Your feedback is appreciated!
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const Tabs = () => (
|
||||
<View style={styles.explainer}>
|
||||
<View style={styles.explainerIcon}>
|
||||
<View style={s.flex1} />
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'clone']}
|
||||
style={[s.black as FontAwesomeIconStyle, s.mb5]}
|
||||
size={36}
|
||||
/>
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
<Text style={styles.explainerHeading}>Tabs</Text>
|
||||
<Text style={styles.explainerDesc}>
|
||||
Never lose your place! Long-press to open posts and profiles in a new tab.
|
||||
</Text>
|
||||
<Text style={styles.explainerDesc}>
|
||||
<Image source={TABS_EXPLAINER} style={styles.explainerImg} />
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const SCENE_MAP = {
|
||||
intro: Intro,
|
||||
tabs: Tabs,
|
||||
}
|
||||
const renderScene = SceneMap(SCENE_MAP)
|
||||
|
||||
export const FeatureExplainer = () => {
|
||||
const layout = useWindowDimensions()
|
||||
const store = useStores()
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
const onPressSkip = () => store.onboard.next()
|
||||
const onPressNext = () => {
|
||||
if (index >= ROUTES.length - 1) {
|
||||
store.onboard.next()
|
||||
} else {
|
||||
setIndex(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const renderTabBar = (props: TabBarProps<Route>) => {
|
||||
const inputRange = props.navigationState.routes.map((x, i) => i)
|
||||
return (
|
||||
<View style={styles.tabBar}>
|
||||
<View style={s.flex1} />
|
||||
{props.navigationState.routes.map((route, i) => {
|
||||
const opacity = props.position.interpolate({
|
||||
inputRange,
|
||||
outputRange: inputRange.map(inputIndex =>
|
||||
inputIndex === i ? 1 : 0.5,
|
||||
),
|
||||
})
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={styles.tabItem}
|
||||
onPress={() => setIndex(i)}>
|
||||
<Animated.Text style={{opacity}}>°</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
<View style={s.flex1} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const FirstExplainer = SCENE_MAP[ROUTES[0]?.key as keyof typeof SCENE_MAP]
|
||||
return (
|
||||
<CenteredView style={styles.container}>
|
||||
{ROUTES.length > 1 ? (
|
||||
<TabView
|
||||
navigationState={{index, routes: ROUTES}}
|
||||
renderScene={renderScene}
|
||||
renderTabBar={renderTabBar}
|
||||
onIndexChange={setIndex}
|
||||
initialLayout={{width: layout.width}}
|
||||
tabBarPosition="bottom"
|
||||
/>
|
||||
) : FirstExplainer ? (
|
||||
<FirstExplainer />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onPressSkip}>
|
||||
<Text style={styles.footerBtn}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onPressNext}>
|
||||
<Text style={[styles.footerBtn, styles.footerBtnNext]}>Next</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '10%',
|
||||
},
|
||||
|
||||
tabBar: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tabItem: {
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
explainer: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
explainerIcon: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
explainerHeading: {
|
||||
fontSize: 42,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
explainerHeadingIntro: {
|
||||
lineHeight: 40,
|
||||
},
|
||||
explainerHeadingBrand: {fontSize: 56},
|
||||
explainerDesc: {
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
color: colors.gray5,
|
||||
},
|
||||
explainerDescIntro: {fontSize: 24},
|
||||
explainerImg: {
|
||||
resizeMode: 'contain',
|
||||
maxWidth: '100%',
|
||||
maxHeight: 330,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 24,
|
||||
},
|
||||
footerBtn: {
|
||||
color: colors.blue3,
|
||||
fontSize: 19,
|
||||
paddingHorizontal: 36,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
footerBtnNext: {
|
||||
marginLeft: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.blue3,
|
||||
borderRadius: 6,
|
||||
},
|
||||
})
|
|
@ -1,55 +0,0 @@
|
|||
import React from 'react'
|
||||
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {SuggestedFollows} from '../discover/SuggestedFollows'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export const Follows = observer(() => {
|
||||
const store = useStores()
|
||||
|
||||
const onNoSuggestions = () => {
|
||||
// no suggestions, bounce from this view
|
||||
store.onboard.next()
|
||||
}
|
||||
const onPressNext = () => store.onboard.next()
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text style={styles.title}>Suggested follows</Text>
|
||||
<View style={s.flex1}>
|
||||
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Skip</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn">
|
||||
<Text style={[s.blue3, s.f18]}>Next</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 24,
|
||||
paddingTop: 16,
|
||||
},
|
||||
})
|
|
@ -1,47 +0,0 @@
|
|||
import React from 'react'
|
||||
import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {SuggestedFollows} from '../discover/SuggestedFollows'
|
||||
import {CenteredView} from '../util/Views.web'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export const Follows = observer(() => {
|
||||
const store = useStores()
|
||||
|
||||
const onNoSuggestions = () => {
|
||||
// no suggestions, bounce from this view
|
||||
store.onboard.next()
|
||||
}
|
||||
const onPressNext = () => store.onboard.next()
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<CenteredView style={styles.header}>
|
||||
<Text type="title-lg">
|
||||
Follow these people to see their posts in your feed
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onPressNext}>
|
||||
<Text style={[styles.title, s.blue3, s.pr10]}>Next »</Text>
|
||||
</TouchableOpacity>
|
||||
</CenteredView>
|
||||
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
header: {
|
||||
paddingTop: 30,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
})
|
|
@ -305,6 +305,8 @@ export const PostThreadItem = observer(function PostThreadItem({
|
|||
authorHandle={item.post.author.handle}
|
||||
authorDisplayName={item.post.author.displayName}
|
||||
timestamp={item.post.indexedAt}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
/>
|
||||
{item.post.author.viewer?.muted ? (
|
||||
<View style={[styles.mutedWarning, pal.btn]}>
|
||||
|
|
|
@ -156,6 +156,8 @@ export const Post = observer(function Post({
|
|||
authorHandle={item.post.author.handle}
|
||||
authorDisplayName={item.post.author.displayName}
|
||||
timestamp={item.post.indexedAt}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
/>
|
||||
{replyAuthorDid !== '' && (
|
||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||
|
|
|
@ -13,16 +13,21 @@ import {EmptyState} from '../util/EmptyState'
|
|||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {FeedModel} from 'state/models/feed-view'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {WelcomeBanner} from '../util/WelcomeBanner'
|
||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||
import {s} from 'lib/styles'
|
||||
import {useAnalytics} from 'lib/analytics'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
||||
const WELCOME_FEED_ITEM = {_reactKey: '__welcome__'}
|
||||
|
||||
export const Feed = observer(function Feed({
|
||||
feed,
|
||||
style,
|
||||
showWelcomeBanner,
|
||||
showPostFollowBtn,
|
||||
scrollElRef,
|
||||
onPressTryAgain,
|
||||
onScroll,
|
||||
|
@ -31,6 +36,8 @@ export const Feed = observer(function Feed({
|
|||
}: {
|
||||
feed: FeedModel
|
||||
style?: StyleProp<ViewStyle>
|
||||
showWelcomeBanner?: boolean
|
||||
showPostFollowBtn?: boolean
|
||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||
onPressTryAgain?: () => void
|
||||
onScroll?: OnScrollCb
|
||||
|
@ -38,7 +45,9 @@ export const Feed = observer(function Feed({
|
|||
headerOffset?: number
|
||||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const store = useStores()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const [isNewUser, setIsNewUser] = React.useState<boolean>(false)
|
||||
|
||||
const data = React.useMemo(() => {
|
||||
let feedItems: any[] = []
|
||||
|
@ -46,6 +55,9 @@ export const Feed = observer(function Feed({
|
|||
if (feed.hasError) {
|
||||
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
||||
}
|
||||
if (showWelcomeBanner && isNewUser) {
|
||||
feedItems = feedItems.concat([WELCOME_FEED_ITEM])
|
||||
}
|
||||
if (feed.isEmpty) {
|
||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||
} else {
|
||||
|
@ -53,21 +65,39 @@ export const Feed = observer(function Feed({
|
|||
}
|
||||
}
|
||||
return feedItems
|
||||
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
|
||||
}, [
|
||||
feed.hasError,
|
||||
feed.hasLoaded,
|
||||
feed.isEmpty,
|
||||
feed.feed,
|
||||
showWelcomeBanner,
|
||||
isNewUser,
|
||||
])
|
||||
|
||||
// events
|
||||
// =
|
||||
|
||||
const checkWelcome = React.useCallback(async () => {
|
||||
if (showWelcomeBanner) {
|
||||
await store.me.follows.fetchIfNeeded()
|
||||
setIsNewUser(store.me.follows.isEmpty)
|
||||
}
|
||||
}, [showWelcomeBanner, store.me.follows])
|
||||
React.useEffect(() => {
|
||||
checkWelcome()
|
||||
}, [checkWelcome])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
track('Feed:onRefresh')
|
||||
setIsRefreshing(true)
|
||||
checkWelcome()
|
||||
try {
|
||||
await feed.refresh()
|
||||
} catch (err) {
|
||||
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
||||
}
|
||||
setIsRefreshing(false)
|
||||
}, [feed, track, setIsRefreshing])
|
||||
}, [feed, track, setIsRefreshing, checkWelcome])
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
track('Feed:onEndReached')
|
||||
try {
|
||||
|
@ -101,10 +131,12 @@ export const Feed = observer(function Feed({
|
|||
onPressTryAgain={onPressTryAgain}
|
||||
/>
|
||||
)
|
||||
} else if (item === WELCOME_FEED_ITEM) {
|
||||
return <WelcomeBanner />
|
||||
}
|
||||
return <FeedItem item={item} />
|
||||
return <FeedItem item={item} showFollowBtn={showPostFollowBtn} />
|
||||
},
|
||||
[feed, onPressTryAgain],
|
||||
[feed, onPressTryAgain, showPostFollowBtn],
|
||||
)
|
||||
|
||||
const FeedFooter = React.useCallback(
|
||||
|
@ -123,6 +155,7 @@ export const Feed = observer(function Feed({
|
|||
<View testID={testID} style={style}>
|
||||
{feed.isLoading && data.length === 0 && (
|
||||
<CenteredView style={{paddingTop: headerOffset}}>
|
||||
{showWelcomeBanner && isNewUser && <WelcomeBanner />}
|
||||
<PostFeedLoadingPlaceholder />
|
||||
</CenteredView>
|
||||
)}
|
||||
|
|
|
@ -26,10 +26,12 @@ import {useAnalytics} from 'lib/analytics'
|
|||
export const FeedItem = observer(function ({
|
||||
item,
|
||||
showReplyLine,
|
||||
showFollowBtn,
|
||||
ignoreMuteFor,
|
||||
}: {
|
||||
item: FeedItemModel
|
||||
showReplyLine?: boolean
|
||||
showFollowBtn?: boolean
|
||||
ignoreMuteFor?: string
|
||||
}) {
|
||||
const store = useStores()
|
||||
|
@ -175,6 +177,9 @@ export const FeedItem = observer(function ({
|
|||
authorHandle={item.post.author.handle}
|
||||
authorDisplayName={item.post.author.displayName}
|
||||
timestamp={item.post.indexedAt}
|
||||
did={item.post.author.did}
|
||||
declarationCid={item.post.author.declaration.cid}
|
||||
showFollowBtn={showFollowBtn}
|
||||
/>
|
||||
{!isChild && replyAuthorDid !== '' && (
|
||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
const FollowButton = observer(
|
||||
({did, declarationCid}: {did: string; declarationCid: string}) => {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const isFollowing = store.me.follows.isFollowing(did)
|
||||
|
||||
const onToggleFollow = async () => {
|
||||
if (store.me.follows.isFollowing(did)) {
|
||||
try {
|
||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await apilib.follow(store, did, declarationCid)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onToggleFollow}>
|
||||
<View style={[styles.btn, pal.btn]}>
|
||||
<Text type="button" style={[pal.text]}>
|
||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default FollowButton
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
btn: {
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
|
@ -1,14 +1,13 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import FollowButton from './FollowButton'
|
||||
|
||||
export function ProfileCard({
|
||||
handle,
|
||||
|
@ -102,26 +101,7 @@ export const ProfileCardWithFollowBtn = observer(
|
|||
}) => {
|
||||
const store = useStores()
|
||||
const isMe = store.me.handle === handle
|
||||
const isFollowing = store.me.follows.isFollowing(did)
|
||||
const onToggleFollow = async () => {
|
||||
if (store.me.follows.isFollowing(did)) {
|
||||
try {
|
||||
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
|
||||
store.me.follows.removeFollow(did)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await apilib.follow(store, did, declarationCid)
|
||||
store.me.follows.addFollow(did, res.uri)
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileCard
|
||||
handle={handle}
|
||||
|
@ -132,34 +112,13 @@ export const ProfileCardWithFollowBtn = observer(
|
|||
renderButton={
|
||||
isMe
|
||||
? undefined
|
||||
: () => (
|
||||
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
|
||||
)
|
||||
: () => <FollowButton did={did} declarationCid={declarationCid} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function FollowBtn({
|
||||
isFollowing,
|
||||
onPress,
|
||||
}: {
|
||||
isFollowing: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={[styles.btn, pal.btn]}>
|
||||
<Text type="button" style={[pal.text]}>
|
||||
{isFollowing ? 'Unfollow' : 'Follow'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
|
|
|
@ -1,37 +1,74 @@
|
|||
import React from 'react'
|
||||
import {Platform, StyleSheet, View} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Text} from './text/Text'
|
||||
import {ago} from 'lib/strings/time'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import FollowButton from '../profile/FollowButton'
|
||||
|
||||
interface PostMetaOpts {
|
||||
authorHandle: string
|
||||
authorDisplayName: string | undefined
|
||||
timestamp: string
|
||||
did: string
|
||||
declarationCid: string
|
||||
showFollowBtn?: boolean
|
||||
}
|
||||
|
||||
export function PostMeta(opts: PostMetaOpts) {
|
||||
export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||
const pal = usePalette('default')
|
||||
let displayName = opts.authorDisplayName || opts.authorHandle
|
||||
let handle = opts.authorHandle
|
||||
const store = useStores()
|
||||
const isMe = opts.did === store.me.did
|
||||
|
||||
// HACK
|
||||
// Android simply cannot handle the truncation case we need
|
||||
// so we have to do it manually here
|
||||
// -prf
|
||||
if (Platform.OS === 'android') {
|
||||
if (displayName.length + handle.length > 26) {
|
||||
if (displayName.length > 26) {
|
||||
displayName = displayName.slice(0, 23) + '...'
|
||||
} else {
|
||||
handle = handle.slice(0, 23 - displayName.length) + '...'
|
||||
if (handle.endsWith('....')) {
|
||||
handle = handle.slice(0, -4) + '...'
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE we capture `isFollowing` via a memo so that follows
|
||||
// don't change this UI immediately, but rather upon future
|
||||
// renders
|
||||
const isFollowing = React.useMemo(
|
||||
() => store.me.follows.isFollowing(opts.did),
|
||||
[opts.did, store.me.follows],
|
||||
)
|
||||
|
||||
if (opts.showFollowBtn && !isMe && !isFollowing) {
|
||||
// two-liner with follow button
|
||||
return (
|
||||
<View style={[styles.metaTwoLine]}>
|
||||
<View>
|
||||
<Text
|
||||
type="lg-bold"
|
||||
style={[pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{displayName}{' '}
|
||||
<Text
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
lineHeight={1.2}>
|
||||
· {ago(opts.timestamp)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text
|
||||
type="md"
|
||||
style={[styles.metaItem, pal.textLight]}
|
||||
lineHeight={1.2}>
|
||||
{handle ? (
|
||||
<Text type="md" style={[pal.textLight]}>
|
||||
@{handle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<FollowButton did={opts.did} declarationCid={opts.declarationCid} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// one-liner
|
||||
return (
|
||||
<View style={styles.meta}>
|
||||
<View style={[styles.metaItem, styles.maxWidth]}>
|
||||
|
@ -53,13 +90,18 @@ export function PostMeta(opts: PostMetaOpts) {
|
|||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
meta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
paddingTop: 0,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
metaTwoLine: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 2,
|
||||
},
|
||||
metaItem: {
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from './text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function WelcomeBanner() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View
|
||||
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 the private beta!
|
||||
</Text>
|
||||
<Text type="lg" style={[pal.text, s.textCenter]}>
|
||||
Here are some recent posts. Follow their creators to build your feed.
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingTop: 30,
|
||||
paddingBottom: 26,
|
||||
paddingHorizontal: 20,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
})
|
|
@ -71,8 +71,6 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
|||
store.log.debug('HomeScreen: Updating feed')
|
||||
if (store.me.mainFeed.hasContent) {
|
||||
store.me.mainFeed.update()
|
||||
} else {
|
||||
store.me.mainFeed.setup()
|
||||
}
|
||||
return cleanup
|
||||
}, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen])
|
||||
|
@ -97,6 +95,8 @@ export const Home = observer(function Home({navIdx, visible}: ScreenParams) {
|
|||
feed={store.me.mainFeed}
|
||||
scrollElRef={scrollElRef}
|
||||
style={s.hContentRegion}
|
||||
showWelcomeBanner
|
||||
showPostFollowBtn
|
||||
onPressTryAgain={onPressTryAgain}
|
||||
onScroll={onMainScroll}
|
||||
headerOffset={HEADER_HEIGHT}
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
import React, {useEffect} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FeatureExplainer} from '../com/onboard/FeatureExplainer'
|
||||
import {Follows} from '../com/onboard/Follows'
|
||||
import {OnboardStage, OnboardStageOrder} from 'state/models/onboard'
|
||||
import {useStores} from 'state/index'
|
||||
|
||||
export const Onboard = observer(() => {
|
||||
const store = useStores()
|
||||
|
||||
useEffect(() => {
|
||||
// sanity check - bounce out of onboarding if the stage is wrong somehow
|
||||
if (!OnboardStageOrder.includes(store.onboard.stage)) {
|
||||
store.onboard.stop()
|
||||
}
|
||||
}, [store.onboard])
|
||||
|
||||
let Com
|
||||
if (store.onboard.stage === OnboardStage.Explainers) {
|
||||
Com = FeatureExplainer
|
||||
} else if (store.onboard.stage === OnboardStage.Follows) {
|
||||
Com = Follows
|
||||
} else {
|
||||
Com = View
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Com />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
height: '100%',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
})
|
|
@ -26,7 +26,6 @@ import {
|
|||
import {match, MatchResult} from '../../routes'
|
||||
import {Login} from '../../screens/Login'
|
||||
import {Menu} from './Menu'
|
||||
import {Onboard} from '../../screens/Onboard'
|
||||
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
|
||||
import {ModalsContainer} from '../../com/modals/Modal'
|
||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||
|
@ -408,17 +407,6 @@ export const MobileShell: React.FC = observer(() => {
|
|||
</View>
|
||||
)
|
||||
}
|
||||
if (store.onboard.isOnboarding) {
|
||||
return (
|
||||
<View testID="onboardOuterView" style={styles.outerContainer}>
|
||||
<View style={styles.innerContainer}>
|
||||
<ErrorBoundary>
|
||||
<Onboard />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const isAtHome =
|
||||
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
|
||||
|
|
|
@ -6,7 +6,6 @@ import {useStores} from 'state/index'
|
|||
import {NavigationModel} from 'state/models/navigation'
|
||||
import {match, MatchResult} from '../../routes'
|
||||
import {DesktopHeader} from './DesktopHeader'
|
||||
import {Onboard} from '../../screens/Onboard'
|
||||
import {Login} from '../../screens/Login'
|
||||
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||
|
@ -35,15 +34,6 @@ export const WebShell: React.FC = observer(() => {
|
|||
</View>
|
||||
)
|
||||
}
|
||||
if (store.onboard.isOnboarding) {
|
||||
return (
|
||||
<View style={styles.outerContainer}>
|
||||
<ErrorBoundary>
|
||||
<Onboard />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.outerContainer, pageBg]}>
|
||||
|
|
Loading…
Reference in New Issue