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 */
|
/* eslint-env detox/detox */
|
||||||
|
|
||||||
describe('Example', () => {
|
describe('Happy paths', () => {
|
||||||
async function grantAccessToUserWithValidCredentials(
|
async function grantAccessToUserWithValidCredentials(
|
||||||
username,
|
username,
|
||||||
{takeScreenshots} = {takeScreenshots: false},
|
{takeScreenshots} = {takeScreenshots: false},
|
||||||
|
@ -65,13 +65,6 @@ describe('Example', () => {
|
||||||
await element(by.id('registerIs13Input')).tap()
|
await element(by.id('registerIs13Input')).tap()
|
||||||
await device.takeScreenshot('4- entered account details')
|
await device.takeScreenshot('4- entered account details')
|
||||||
await element(by.id('createAccountButton')).tap()
|
await element(by.id('createAccountButton')).tap()
|
||||||
await expect(element(by.id('onboardFeatureExplainerSkipBtn'))).toBeVisible()
|
await expect(element(by.id('welcomeBanner'))).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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -26,9 +26,9 @@ PODS:
|
||||||
- libwebp/mux (1.2.4):
|
- libwebp/mux (1.2.4):
|
||||||
- libwebp/demux
|
- libwebp/demux
|
||||||
- libwebp/webp (1.2.4)
|
- libwebp/webp (1.2.4)
|
||||||
- Permission-Camera (3.6.1):
|
- Permission-Camera (3.7.2):
|
||||||
- RNPermissions
|
- RNPermissions
|
||||||
- Permission-PhotoLibrary (3.6.1):
|
- Permission-PhotoLibrary (3.7.2):
|
||||||
- RNPermissions
|
- RNPermissions
|
||||||
- RCT-Folly (2021.07.22.00):
|
- RCT-Folly (2021.07.22.00):
|
||||||
- boost
|
- boost
|
||||||
|
@ -359,7 +359,7 @@ PODS:
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNCAsyncStorage (1.17.11):
|
- RNCAsyncStorage (1.17.11):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNCClipboard (1.11.1):
|
- RNCClipboard (1.11.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNFastImage (8.6.3):
|
- RNFastImage (8.6.3):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -385,7 +385,7 @@ PODS:
|
||||||
- RNNotifee/NotifeeCore (= 7.5.0)
|
- RNNotifee/NotifeeCore (= 7.5.0)
|
||||||
- RNNotifee/NotifeeCore (7.5.0):
|
- RNNotifee/NotifeeCore (7.5.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNPermissions (3.6.1):
|
- RNPermissions (3.7.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReactNativeHapticFeedback (1.14.0):
|
- RNReactNativeHapticFeedback (1.14.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -652,8 +652,8 @@ SPEC CHECKSUMS:
|
||||||
hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
|
hermes-engine: 922ccd744f50d9bfde09e9677bf0f3b562ea5fb9
|
||||||
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
|
||||||
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
|
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
|
||||||
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
|
Permission-Camera: db22e80aa0858a8b6d65979a97f2f481dd8a0ebd
|
||||||
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
|
Permission-PhotoLibrary: 7d80161682e08042fd8b0bf934ea97a8495e0e6a
|
||||||
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
|
||||||
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
|
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
|
||||||
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
|
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
|
||||||
|
@ -693,14 +693,14 @@ SPEC CHECKSUMS:
|
||||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||||
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
||||||
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
||||||
RNCClipboard: 2834e1c4af68697089cdd455ee4a4cdd198fa7dd
|
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
||||||
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
||||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||||
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
||||||
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
||||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||||
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
|
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
|
||||||
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
|
RNPermissions: 2fbbcb7244357507f958d626d58eb15fb0013d85
|
||||||
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
||||||
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
||||||
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
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_DISPLAY_NAME = 64
|
||||||
export const MAX_DESCRIPTION = 256
|
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 = [
|
export const PROD_SUGGESTED_FOLLOWS = [
|
||||||
'john',
|
'john',
|
||||||
'visakanv',
|
'visakanv',
|
||||||
|
@ -55,14 +80,21 @@ export const PROD_SUGGESTED_FOLLOWS = [
|
||||||
'jay',
|
'jay',
|
||||||
'paul',
|
'paul',
|
||||||
].map(handle => `${handle}.bsky.social`)
|
].map(handle => `${handle}.bsky.social`)
|
||||||
|
|
||||||
export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
|
export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
|
||||||
handle => `${handle}.staging.bsky.dev`,
|
handle => `${handle}.staging.bsky.dev`,
|
||||||
)
|
)
|
||||||
|
|
||||||
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
|
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
|
||||||
handle => `${handle}.test`,
|
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_WIDTH = 2000
|
||||||
export const POST_IMG_MAX_HEIGHT = 2000
|
export const POST_IMG_MAX_HEIGHT = 2000
|
||||||
|
|
|
@ -62,6 +62,10 @@ export const s = StyleSheet.create({
|
||||||
footerSpacer: {height: 100},
|
footerSpacer: {height: 100},
|
||||||
contentContainer: {paddingBottom: 200},
|
contentContainer: {paddingBottom: 200},
|
||||||
border1: {borderWidth: 1},
|
border1: {borderWidth: 1},
|
||||||
|
borderTop1: {borderTopWidth: 1},
|
||||||
|
borderRight1: {borderRightWidth: 1},
|
||||||
|
borderBottom1: {borderBottomWidth: 1},
|
||||||
|
borderLeft1: {borderLeftWidth: 1},
|
||||||
|
|
||||||
// font weights
|
// font weights
|
||||||
fw600: {fontWeight: '600'},
|
fw600: {fontWeight: '600'},
|
||||||
|
|
|
@ -15,6 +15,12 @@ import {RootStoreModel} from './root-store'
|
||||||
import * as apilib from 'lib/api/index'
|
import * as apilib from 'lib/api/index'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {RichText} from 'lib/strings/rich-text'
|
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
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
|
@ -535,11 +541,31 @@ export class FeedModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _getFeed(
|
protected async _getFeed(
|
||||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
||||||
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
||||||
params = Object.assign({}, this.params, params)
|
params = Object.assign({}, this.params, params)
|
||||||
if (this.feedType === 'home') {
|
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(
|
return this.rootStore.api.app.bsky.feed.getTimeline(
|
||||||
params as GetTimeline.QueryParams,
|
params as GetTimeline.QueryParams,
|
||||||
)
|
)
|
||||||
|
|
|
@ -96,6 +96,7 @@ export class MeModel {
|
||||||
this.avatar = ''
|
this.avatar = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.mainFeed.clear()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.mainFeed.setup().catch(e => {
|
this.mainFeed.setup().catch(e => {
|
||||||
this.rootStore.log.error('Failed to setup main feed model', e)
|
this.rootStore.log.error('Failed to setup main feed model', e)
|
||||||
|
|
|
@ -20,6 +20,7 @@ export class MyFollowsModel {
|
||||||
// data
|
// data
|
||||||
followDidToRecordMap: Record<string, string> = {}
|
followDidToRecordMap: Record<string, string> = {}
|
||||||
lastSync = 0
|
lastSync = 0
|
||||||
|
myDid?: string
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -36,6 +37,7 @@ export class MyFollowsModel {
|
||||||
|
|
||||||
fetchIfNeeded = bundleAsync(async () => {
|
fetchIfNeeded = bundleAsync(async () => {
|
||||||
if (
|
if (
|
||||||
|
this.myDid !== this.rootStore.me.did ||
|
||||||
Object.keys(this.followDidToRecordMap).length === 0 ||
|
Object.keys(this.followDidToRecordMap).length === 0 ||
|
||||||
Date.now() - this.lastSync > CACHE_TTL
|
Date.now() - this.lastSync > CACHE_TTL
|
||||||
) {
|
) {
|
||||||
|
@ -62,6 +64,7 @@ export class MyFollowsModel {
|
||||||
this.followDidToRecordMap[record.value.subject.did] = record.uri
|
this.followDidToRecordMap[record.value.subject.did] = record.uri
|
||||||
}
|
}
|
||||||
this.lastSync = Date.now()
|
this.lastSync = Date.now()
|
||||||
|
this.myDid = this.rootStore.me.did
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -69,6 +72,10 @@ export class MyFollowsModel {
|
||||||
return !!this.followDidToRecordMap[did]
|
return !!this.followDidToRecordMap[did]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return Object.keys(this.followDidToRecordMap).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
getFollowUri(did: string): string {
|
getFollowUri(did: string): string {
|
||||||
const v = this.followDidToRecordMap[did]
|
const v = this.followDidToRecordMap[did]
|
||||||
if (!v) {
|
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 {LinkMetasViewModel} from './link-metas-view'
|
||||||
import {NotificationsViewItemModel} from './notifications-view'
|
import {NotificationsViewItemModel} from './notifications-view'
|
||||||
import {MeModel} from './me'
|
import {MeModel} from './me'
|
||||||
import {OnboardModel} from './onboard'
|
|
||||||
|
|
||||||
export const appInfo = z.object({
|
export const appInfo = z.object({
|
||||||
build: z.string(),
|
build: z.string(),
|
||||||
|
@ -35,7 +34,6 @@ export class RootStoreModel {
|
||||||
nav = new NavigationModel(this)
|
nav = new NavigationModel(this)
|
||||||
shell = new ShellUiModel(this)
|
shell = new ShellUiModel(this)
|
||||||
me = new MeModel(this)
|
me = new MeModel(this)
|
||||||
onboard = new OnboardModel()
|
|
||||||
profiles = new ProfilesViewModel(this)
|
profiles = new ProfilesViewModel(this)
|
||||||
linkMetas = new LinkMetasViewModel(this)
|
linkMetas = new LinkMetasViewModel(this)
|
||||||
|
|
||||||
|
@ -85,7 +83,6 @@ export class RootStoreModel {
|
||||||
session: this.session.serialize(),
|
session: this.session.serialize(),
|
||||||
me: this.me.serialize(),
|
me: this.me.serialize(),
|
||||||
nav: this.nav.serialize(),
|
nav: this.nav.serialize(),
|
||||||
onboard: this.onboard.serialize(),
|
|
||||||
shell: this.shell.serialize(),
|
shell: this.shell.serialize(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,9 +104,6 @@ export class RootStoreModel {
|
||||||
if (hasProp(v, 'nav')) {
|
if (hasProp(v, 'nav')) {
|
||||||
this.nav.hydrate(v.nav)
|
this.nav.hydrate(v.nav)
|
||||||
}
|
}
|
||||||
if (hasProp(v, 'onboard')) {
|
|
||||||
this.onboard.hydrate(v.onboard)
|
|
||||||
}
|
|
||||||
if (hasProp(v, 'session')) {
|
if (hasProp(v, 'session')) {
|
||||||
this.session.hydrate(v.session)
|
this.session.hydrate(v.session)
|
||||||
}
|
}
|
||||||
|
|
|
@ -345,7 +345,6 @@ export class SessionModel {
|
||||||
)
|
)
|
||||||
|
|
||||||
this.setActiveSession(agent, did)
|
this.setActiveSession(agent, did)
|
||||||
this.rootStore.onboard.start()
|
|
||||||
this.rootStore.log.debug('SessionModel:createAccount succeeded')
|
this.rootStore.log.debug('SessionModel:createAccount succeeded')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,26 +4,12 @@ import shuffle from 'lodash.shuffle'
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {bundleAsync} from 'lib/async/bundle'
|
import {bundleAsync} from 'lib/async/bundle'
|
||||||
import {
|
import {SUGGESTED_FOLLOWS} from 'lib/constants'
|
||||||
DEV_SUGGESTED_FOLLOWS,
|
|
||||||
PROD_SUGGESTED_FOLLOWS,
|
|
||||||
STAGING_SUGGESTED_FOLLOWS,
|
|
||||||
} from 'lib/constants'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
export type SuggestedActor = Profile.ViewBasic | Profile.View
|
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 {
|
export class SuggestedActorsViewModel {
|
||||||
// state
|
// state
|
||||||
pageSize = PAGE_SIZE
|
pageSize = PAGE_SIZE
|
||||||
|
@ -126,9 +112,9 @@ export class SuggestedActorsViewModel {
|
||||||
try {
|
try {
|
||||||
// clone the array so we can mutate it
|
// clone the array so we can mutate it
|
||||||
const actors = [
|
const actors = [
|
||||||
...getSuggestionList({
|
...SUGGESTED_FOLLOWS(
|
||||||
serviceUrl: this.rootStore.session.currentSession?.service || '',
|
this.rootStore.session.currentSession?.service || '',
|
||||||
}),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
|
// fetch the profiles in chunks of 25 (the limit allowed by `getProfiles`)
|
||||||
|
|
|
@ -1,21 +1,12 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {
|
|
||||||
AppBskyFeedFeedViewPost,
|
|
||||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
|
||||||
} from '@atproto/api'
|
|
||||||
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
|
||||||
import {RootStoreModel} from './root-store'
|
import {RootStoreModel} from './root-store'
|
||||||
import {FeedItemModel} from './feed-view'
|
import {FeedItemModel} from './feed-view'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
|
import {TEAM_HANDLES} from 'lib/constants'
|
||||||
const TEAM_HANDLES = [
|
import {
|
||||||
'jay.bsky.social',
|
getMultipleAuthorsPosts,
|
||||||
'paul.bsky.social',
|
mergePosts,
|
||||||
'dan.bsky.social',
|
} from 'lib/api/build-suggested-posts'
|
||||||
'divy.bsky.social',
|
|
||||||
'why.bsky.social',
|
|
||||||
'iamrosewang.bsky.social',
|
|
||||||
]
|
|
||||||
|
|
||||||
export class SuggestedPostsView {
|
export class SuggestedPostsView {
|
||||||
// state
|
// state
|
||||||
|
@ -54,15 +45,18 @@ export class SuggestedPostsView {
|
||||||
async setup() {
|
async setup() {
|
||||||
this._xLoading()
|
this._xLoading()
|
||||||
try {
|
try {
|
||||||
const responses = await Promise.all(
|
const responses = await getMultipleAuthorsPosts(
|
||||||
TEAM_HANDLES.map(handle =>
|
this.rootStore,
|
||||||
this.rootStore.api.app.bsky.feed
|
TEAM_HANDLES(String(this.rootStore.agent.service)),
|
||||||
.getAuthorFeed({author: handle, limit: 10})
|
|
||||||
.catch(_err => ({success: false, headers: {}, data: {feed: []}})),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
runInAction(() => {
|
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()
|
this._xIdle()
|
||||||
} catch (e: any) {
|
} 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]}>
|
<View style={[pal.border, styles.bottomBorder]}>
|
||||||
{suggestedPostsView.posts.map(item => (
|
{suggestedPostsView.posts.map(item => (
|
||||||
<Post item={item} key={item._reactKey} />
|
<Post item={item} key={item._reactKey} showFollowBtn />
|
||||||
))}
|
))}
|
||||||
</View>
|
</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}
|
authorHandle={item.post.author.handle}
|
||||||
authorDisplayName={item.post.author.displayName}
|
authorDisplayName={item.post.author.displayName}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={item.post.indexedAt}
|
||||||
|
did={item.post.author.did}
|
||||||
|
declarationCid={item.post.author.declaration.cid}
|
||||||
/>
|
/>
|
||||||
{item.post.author.viewer?.muted ? (
|
{item.post.author.viewer?.muted ? (
|
||||||
<View style={[styles.mutedWarning, pal.btn]}>
|
<View style={[styles.mutedWarning, pal.btn]}>
|
||||||
|
|
|
@ -156,6 +156,8 @@ export const Post = observer(function Post({
|
||||||
authorHandle={item.post.author.handle}
|
authorHandle={item.post.author.handle}
|
||||||
authorDisplayName={item.post.author.displayName}
|
authorDisplayName={item.post.author.displayName}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={item.post.indexedAt}
|
||||||
|
did={item.post.author.did}
|
||||||
|
declarationCid={item.post.author.declaration.cid}
|
||||||
/>
|
/>
|
||||||
{replyAuthorDid !== '' && (
|
{replyAuthorDid !== '' && (
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||||
|
|
|
@ -13,16 +13,21 @@ import {EmptyState} from '../util/EmptyState'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {FeedModel} from 'state/models/feed-view'
|
import {FeedModel} from 'state/models/feed-view'
|
||||||
import {FeedItem} from './FeedItem'
|
import {FeedItem} from './FeedItem'
|
||||||
|
import {WelcomeBanner} from '../util/WelcomeBanner'
|
||||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
|
||||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
||||||
|
const WELCOME_FEED_ITEM = {_reactKey: '__welcome__'}
|
||||||
|
|
||||||
export const Feed = observer(function Feed({
|
export const Feed = observer(function Feed({
|
||||||
feed,
|
feed,
|
||||||
style,
|
style,
|
||||||
|
showWelcomeBanner,
|
||||||
|
showPostFollowBtn,
|
||||||
scrollElRef,
|
scrollElRef,
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
@ -31,6 +36,8 @@ export const Feed = observer(function Feed({
|
||||||
}: {
|
}: {
|
||||||
feed: FeedModel
|
feed: FeedModel
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
showWelcomeBanner?: boolean
|
||||||
|
showPostFollowBtn?: boolean
|
||||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScroll?: OnScrollCb
|
onScroll?: OnScrollCb
|
||||||
|
@ -38,7 +45,9 @@ export const Feed = observer(function Feed({
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
}) {
|
}) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
|
const store = useStores()
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
|
const [isNewUser, setIsNewUser] = React.useState<boolean>(false)
|
||||||
|
|
||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
let feedItems: any[] = []
|
let feedItems: any[] = []
|
||||||
|
@ -46,6 +55,9 @@ export const Feed = observer(function Feed({
|
||||||
if (feed.hasError) {
|
if (feed.hasError) {
|
||||||
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
||||||
}
|
}
|
||||||
|
if (showWelcomeBanner && isNewUser) {
|
||||||
|
feedItems = feedItems.concat([WELCOME_FEED_ITEM])
|
||||||
|
}
|
||||||
if (feed.isEmpty) {
|
if (feed.isEmpty) {
|
||||||
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
|
||||||
} else {
|
} else {
|
||||||
|
@ -53,21 +65,39 @@ export const Feed = observer(function Feed({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return feedItems
|
return feedItems
|
||||||
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
|
}, [
|
||||||
|
feed.hasError,
|
||||||
|
feed.hasLoaded,
|
||||||
|
feed.isEmpty,
|
||||||
|
feed.feed,
|
||||||
|
showWelcomeBanner,
|
||||||
|
isNewUser,
|
||||||
|
])
|
||||||
|
|
||||||
// events
|
// 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 () => {
|
const onRefresh = React.useCallback(async () => {
|
||||||
track('Feed:onRefresh')
|
track('Feed:onRefresh')
|
||||||
setIsRefreshing(true)
|
setIsRefreshing(true)
|
||||||
|
checkWelcome()
|
||||||
try {
|
try {
|
||||||
await feed.refresh()
|
await feed.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
feed.rootStore.log.error('Failed to refresh posts feed', err)
|
||||||
}
|
}
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}, [feed, track, setIsRefreshing])
|
}, [feed, track, setIsRefreshing, checkWelcome])
|
||||||
const onEndReached = React.useCallback(async () => {
|
const onEndReached = React.useCallback(async () => {
|
||||||
track('Feed:onEndReached')
|
track('Feed:onEndReached')
|
||||||
try {
|
try {
|
||||||
|
@ -101,10 +131,12 @@ export const Feed = observer(function Feed({
|
||||||
onPressTryAgain={onPressTryAgain}
|
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(
|
const FeedFooter = React.useCallback(
|
||||||
|
@ -123,6 +155,7 @@ export const Feed = observer(function Feed({
|
||||||
<View testID={testID} style={style}>
|
<View testID={testID} style={style}>
|
||||||
{feed.isLoading && data.length === 0 && (
|
{feed.isLoading && data.length === 0 && (
|
||||||
<CenteredView style={{paddingTop: headerOffset}}>
|
<CenteredView style={{paddingTop: headerOffset}}>
|
||||||
|
{showWelcomeBanner && isNewUser && <WelcomeBanner />}
|
||||||
<PostFeedLoadingPlaceholder />
|
<PostFeedLoadingPlaceholder />
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -26,10 +26,12 @@ import {useAnalytics} from 'lib/analytics'
|
||||||
export const FeedItem = observer(function ({
|
export const FeedItem = observer(function ({
|
||||||
item,
|
item,
|
||||||
showReplyLine,
|
showReplyLine,
|
||||||
|
showFollowBtn,
|
||||||
ignoreMuteFor,
|
ignoreMuteFor,
|
||||||
}: {
|
}: {
|
||||||
item: FeedItemModel
|
item: FeedItemModel
|
||||||
showReplyLine?: boolean
|
showReplyLine?: boolean
|
||||||
|
showFollowBtn?: boolean
|
||||||
ignoreMuteFor?: string
|
ignoreMuteFor?: string
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
|
@ -175,6 +177,9 @@ export const FeedItem = observer(function ({
|
||||||
authorHandle={item.post.author.handle}
|
authorHandle={item.post.author.handle}
|
||||||
authorDisplayName={item.post.author.displayName}
|
authorDisplayName={item.post.author.displayName}
|
||||||
timestamp={item.post.indexedAt}
|
timestamp={item.post.indexedAt}
|
||||||
|
did={item.post.author.did}
|
||||||
|
declarationCid={item.post.author.declaration.cid}
|
||||||
|
showFollowBtn={showFollowBtn}
|
||||||
/>
|
/>
|
||||||
{!isChild && replyAuthorDid !== '' && (
|
{!isChild && replyAuthorDid !== '' && (
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
<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 React from 'react'
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import * as Toast from '../util/Toast'
|
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import * as apilib from 'lib/api/index'
|
import FollowButton from './FollowButton'
|
||||||
|
|
||||||
export function ProfileCard({
|
export function ProfileCard({
|
||||||
handle,
|
handle,
|
||||||
|
@ -102,26 +101,7 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
}) => {
|
}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const isMe = store.me.handle === handle
|
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 (
|
return (
|
||||||
<ProfileCard
|
<ProfileCard
|
||||||
handle={handle}
|
handle={handle}
|
||||||
|
@ -132,34 +112,13 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
renderButton={
|
renderButton={
|
||||||
isMe
|
isMe
|
||||||
? undefined
|
? undefined
|
||||||
: () => (
|
: () => <FollowButton did={did} declarationCid={declarationCid} />
|
||||||
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
|
|
|
@ -1,37 +1,74 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Platform, StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {Text} from './text/Text'
|
import {Text} from './text/Text'
|
||||||
import {ago} from 'lib/strings/time'
|
import {ago} from 'lib/strings/time'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import FollowButton from '../profile/FollowButton'
|
||||||
|
|
||||||
interface PostMetaOpts {
|
interface PostMetaOpts {
|
||||||
authorHandle: string
|
authorHandle: string
|
||||||
authorDisplayName: string | undefined
|
authorDisplayName: string | undefined
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
did: string
|
||||||
|
declarationCid: string
|
||||||
|
showFollowBtn?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostMeta(opts: PostMetaOpts) {
|
export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
let displayName = opts.authorDisplayName || opts.authorHandle
|
let displayName = opts.authorDisplayName || opts.authorHandle
|
||||||
let handle = opts.authorHandle
|
let handle = opts.authorHandle
|
||||||
|
const store = useStores()
|
||||||
|
const isMe = opts.did === store.me.did
|
||||||
|
|
||||||
// HACK
|
// NOTE we capture `isFollowing` via a memo so that follows
|
||||||
// Android simply cannot handle the truncation case we need
|
// don't change this UI immediately, but rather upon future
|
||||||
// so we have to do it manually here
|
// renders
|
||||||
// -prf
|
const isFollowing = React.useMemo(
|
||||||
if (Platform.OS === 'android') {
|
() => store.me.follows.isFollowing(opts.did),
|
||||||
if (displayName.length + handle.length > 26) {
|
[opts.did, store.me.follows],
|
||||||
if (displayName.length > 26) {
|
)
|
||||||
displayName = displayName.slice(0, 23) + '...'
|
|
||||||
} else {
|
if (opts.showFollowBtn && !isMe && !isFollowing) {
|
||||||
handle = handle.slice(0, 23 - displayName.length) + '...'
|
// two-liner with follow button
|
||||||
if (handle.endsWith('....')) {
|
return (
|
||||||
handle = handle.slice(0, -4) + '...'
|
<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 (
|
return (
|
||||||
<View style={styles.meta}>
|
<View style={styles.meta}>
|
||||||
<View style={[styles.metaItem, styles.maxWidth]}>
|
<View style={[styles.metaItem, styles.maxWidth]}>
|
||||||
|
@ -53,13 +90,18 @@ export function PostMeta(opts: PostMetaOpts) {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
meta: {
|
meta: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'baseline',
|
alignItems: 'baseline',
|
||||||
paddingTop: 0,
|
paddingBottom: 2,
|
||||||
|
},
|
||||||
|
metaTwoLine: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
paddingBottom: 2,
|
paddingBottom: 2,
|
||||||
},
|
},
|
||||||
metaItem: {
|
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')
|
store.log.debug('HomeScreen: Updating feed')
|
||||||
if (store.me.mainFeed.hasContent) {
|
if (store.me.mainFeed.hasContent) {
|
||||||
store.me.mainFeed.update()
|
store.me.mainFeed.update()
|
||||||
} else {
|
|
||||||
store.me.mainFeed.setup()
|
|
||||||
}
|
}
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [visible, store, store.me.mainFeed, navIdx, doPoll, wasVisible, scrollToTop, screen])
|
}, [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}
|
feed={store.me.mainFeed}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
style={s.hContentRegion}
|
style={s.hContentRegion}
|
||||||
|
showWelcomeBanner
|
||||||
|
showPostFollowBtn
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
headerOffset={HEADER_HEIGHT}
|
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 {match, MatchResult} from '../../routes'
|
||||||
import {Login} from '../../screens/Login'
|
import {Login} from '../../screens/Login'
|
||||||
import {Menu} from './Menu'
|
import {Menu} from './Menu'
|
||||||
import {Onboard} from '../../screens/Onboard'
|
|
||||||
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
|
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
|
||||||
import {ModalsContainer} from '../../com/modals/Modal'
|
import {ModalsContainer} from '../../com/modals/Modal'
|
||||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||||
|
@ -408,17 +407,6 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (store.onboard.isOnboarding) {
|
|
||||||
return (
|
|
||||||
<View testID="onboardOuterView" style={styles.outerContainer}>
|
|
||||||
<View style={styles.innerContainer}>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Onboard />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAtHome =
|
const isAtHome =
|
||||||
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
|
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {useStores} from 'state/index'
|
||||||
import {NavigationModel} from 'state/models/navigation'
|
import {NavigationModel} from 'state/models/navigation'
|
||||||
import {match, MatchResult} from '../../routes'
|
import {match, MatchResult} from '../../routes'
|
||||||
import {DesktopHeader} from './DesktopHeader'
|
import {DesktopHeader} from './DesktopHeader'
|
||||||
import {Onboard} from '../../screens/Onboard'
|
|
||||||
import {Login} from '../../screens/Login'
|
import {Login} from '../../screens/Login'
|
||||||
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
||||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||||
|
@ -35,15 +34,6 @@ export const WebShell: React.FC = observer(() => {
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (store.onboard.isOnboarding) {
|
|
||||||
return (
|
|
||||||
<View style={styles.outerContainer}>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Onboard />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.outerContainer, pageBg]}>
|
<View style={[styles.outerContainer, pageBg]}>
|
||||||
|
|
Loading…
Reference in New Issue