Add custom feeds selector, rework search, simplify onboarding (#325)

* Get home screen's swipable pager working with the drawer

* Add tab bar to pager

* Implement popular & following views on home screen

* Visual tune-up

* Move the feed selector to the footer

* Fix to 'new posts' poll

* Add the view header as a feed item

* Use the native driver on the tabbar indicator to improve perf

* Reduce home polling to the currently active page; also reuse some code

* Add soft reset on tap selected in tab bar

* Remove explicit 'onboarding' flow

* Choose good stuff based on service

* Add foaf-based follow discovery

* Fall back to who to follow

* Fix backgrounds

* Switch to the off-spec goodstuff route

* 1.8

* Fix for dev & staging

* Swap the tab bar items and rename suggested to what's hot

* Go to whats-hot by default if you have no follows

* Implement pager and tabbar for desktop web

* Pin deps to make expo happy

* Add language filtering to goodstuff
zio/stable
Paul Frazee 2023-03-19 18:53:57 -05:00 committed by GitHub
parent c31ffdac1b
commit 1de724b24b
33 changed files with 1634 additions and 692 deletions

View File

@ -2,7 +2,7 @@
"expo": {
"name": "bluesky",
"slug": "bluesky",
"version": "1.7.0",
"version": "1.8.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",

View File

@ -19,10 +19,10 @@ PODS:
- EXJSONUtils (0.5.1)
- EXManifests (0.5.2):
- EXJSONUtils
- EXMediaLibrary (15.2.2):
- EXMediaLibrary (15.2.3):
- ExpoModulesCore
- React-Core
- Expo (48.0.6):
- Expo (48.0.7):
- ExpoModulesCore
- expo-dev-client (2.1.5):
- EXManifests
@ -100,7 +100,7 @@ PODS:
- ExpoModulesCore
- ExpoKeepAwake (12.0.1):
- ExpoModulesCore
- ExpoModulesCore (1.2.4):
- ExpoModulesCore (1.2.5):
- React-Core
- React-RCTAppDelegate
- ReactCommon/turbomodule/core
@ -384,10 +384,12 @@ PODS:
- glog
- react-native-blur (4.3.0):
- React-Core
- react-native-cameraroll (5.2.4):
- react-native-cameraroll (5.3.1):
- React-Core
- react-native-image-resizer (3.0.5):
- React-Core
- react-native-pager-view (6.1.2):
- React-Core
- react-native-paste-input (0.6.2):
- React-Core
- Swime (= 3.0.6)
@ -401,7 +403,7 @@ PODS:
- React-Core
- react-native-version-number (0.3.6):
- React
- react-native-webview (11.26.1):
- react-native-webview (11.26.0):
- React-Core
- React-perflogger (0.71.3)
- React-RCTActionSheet (0.71.3):
@ -489,9 +491,9 @@ PODS:
- React-perflogger (= 0.71.3)
- rn-fetch-blob (0.12.0):
- React-Core
- RNBackgroundFetch (4.1.8):
- RNBackgroundFetch (4.1.9):
- React-Core
- RNCAsyncStorage (1.17.11):
- RNCAsyncStorage (1.17.12):
- React-Core
- RNCClipboard (1.11.2):
- React-Core
@ -514,10 +516,10 @@ PODS:
- TOCropViewController
- RNInAppBrowser (3.7.0):
- React-Core
- RNNotifee (7.5.0):
- RNNotifee (7.6.1):
- React-Core
- RNNotifee/NotifeeCore (= 7.5.0)
- RNNotifee/NotifeeCore (7.5.0):
- RNNotifee/NotifeeCore (= 7.6.1)
- RNNotifee/NotifeeCore (7.6.1):
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
@ -551,7 +553,7 @@ PODS:
- RNScreens (3.20.0):
- React-Core
- React-RCTImage
- RNSVG (13.8.0):
- RNSVG (13.4.0):
- React-Core
- SDWebImage (5.11.1):
- SDWebImage/Core (= 5.11.1)
@ -559,7 +561,7 @@ PODS:
- SDWebImageWebPCoder (0.8.5):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- segment-analytics-react-native (2.13.1):
- segment-analytics-react-native (2.13.4):
- React-Core
- sovran-react-native
- sovran-react-native (0.4.5):
@ -614,6 +616,7 @@ DEPENDENCIES:
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
- "react-native-image-resizer (from `../node_modules/@bam.tech/react-native-image-resizer`)"
- react-native-pager-view (from `../node_modules/react-native-pager-view`)
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
@ -747,6 +750,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-camera-roll/camera-roll"
react-native-image-resizer:
:path: "../node_modules/@bam.tech/react-native-image-resizer"
react-native-pager-view:
:path: "../node_modules/react-native-pager-view"
react-native-paste-input:
:path: "../node_modules/@mattermost/react-native-paste-input"
react-native-safe-area-context:
@ -830,15 +835,15 @@ SPEC CHECKSUMS:
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
EXJSONUtils: 48b1e764ac35160e6f54d21ab60d7d9501f3e473
EXManifests: 500666d48e8dd7ca5a482c9e729e4a7a6c34081b
EXMediaLibrary: 792fe9b828b5bfa2c5a8b629730f175af2938285
Expo: 04ba1ddde0be07aff4306ae636a1804810679145
EXMediaLibrary: 587cd8aad27a6fc8d7c38b950bc75bc1845a7480
Expo: 707f9b0039eacc6a1dce90c08c9e37b9c417bba2
expo-dev-client: 7c1ef51516853465f4d448c14ddf365167d20361
expo-dev-launcher: 90de99d9e5d1a883d81355ca10e87c2f3c81d46e
expo-dev-menu: d4369e74d8d21a0ccdee35f7c732e7118b0fee16
expo-dev-menu-interface: 6c82ae323c4b8724dead4763ce3ff24a2108bdb1
ExpoImagePicker: 270dea232b3a072d981dd564e2cafc63a864edb1
ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a
ExpoModulesCore: 1667335d4f4c9b7801990930e6f0eea42c916a21
ExpoModulesCore: 397fc99e9d6c9dcc010f36d5802097c17b90424c
EXSplashScreen: cd7fb052dff5ba8311d5c2455ecbebffe1b7a8ca
EXUpdatesInterface: dd699d1930e28639dcbd70a402caea98e86364ca
FBLazyVector: 60195509584153283780abdac5569feffb8f08cc
@ -863,13 +868,14 @@ SPEC CHECKSUMS:
React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd
React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
react-native-cameraroll: cb752fda6d5268f1646b4390bd5be1f27706b9a0
react-native-cameraroll: f3050460fe1708378698c16686bfaa5f34099be2
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
react-native-paste-input: 3392800944a47c00dddbff23c31c281482209679
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
react-native-webview: 994b9f8fbb504d6314dc40d83f94f27c6831b3bf
React-perflogger: af8a3d31546077f42d729b949925cc4549f14def
React-RCTActionSheet: 57cc5adfefbaaf0aae2cf7e10bccd746f2903673
React-RCTAnimation: 11c61e94da700c4dc915cf134513764d87fc5e2b
@ -884,22 +890,22 @@ SPEC CHECKSUMS:
React-runtimeexecutor: 7bf0dafc7b727d93c8cb94eb00a9d3753c446c3e
ReactCommon: 6f65ea5b7d84deb9e386f670dd11ce499ded7b40
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
RNBackgroundFetch: 642777e4e76435773c149d565a043d66f1781237
RNCAsyncStorage: 09fc8595e6d6f6d5abf16b23a56b257d9c6b7c5b
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
RNNotifee: bdc064c29f4d558046f51f0c3ae02bab4fd3cd85
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
RNSVG: c1e76b81c76cdcd34b4e1188852892dc280eb902
RNSVG: 07dbd870b0dcdecc99b3a202fa37c8ca163caec2
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
segment-analytics-react-native: f962dff3a084655a29f9403b8c139c75a3362524
segment-analytics-react-native: cc12d9422f7ce863ee57c1b650ab48eec4b6d5bd
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863

View File

@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.7</string>
<string>1.8</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@ -1,8 +1,9 @@
{
"name": "bsky.app",
"version": "1.7.0",
"version": "1.8.0",
"private": true,
"scripts": {
"postinstall": "patch-package",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
@ -58,12 +59,13 @@
"expo-camera": "~13.2.1",
"expo-dev-client": "~2.1.1",
"expo-image-picker": "~14.1.1",
"expo-media-library": "~15.2.1",
"expo-media-library": "~15.2.3",
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4",
"he": "^1.2.0",
"history": "^5.3.0",
"js-sha256": "^0.9.0",
"lande": "^1.0.10",
"lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.debounce": "^4.0.8",
@ -75,6 +77,8 @@
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"react": "18.2.0",
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
@ -90,21 +94,21 @@
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.1.2",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "~2.14.4",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "^4.4.1",
"react-native-screens": "^3.13.1",
"react-native-splash-screen": "^3.3.0",
"react-native-svg": "^13.4.0",
"react-native-tab-view": "^3.3.0",
"react-native-svg": "13.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6",
"react-native-web": "^0.18.11",
"react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "^11.26.1",
"react-native-webview": "11.26.0",
"react-native-youtube-iframe": "^2.2.2",
"rn-fetch-blob": "^0.12.0",
"tippy.js": "^6.3.7",

View File

@ -0,0 +1,54 @@
diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
index ab0fc7f..fbbf19f 100644
--- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
+++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
@@ -1,6 +1,6 @@
#import "ReactNativePageView.h"
-#import "React/RCTLog.h"
+#import <React/RCTLog.h>
#import <React/RCTViewManager.h>
#import "UIViewController+CreateExtension.h"
@@ -9,7 +9,7 @@
#import "RCTOnPageSelected.h"
#import <math.h>
-@interface ReactNativePageView () <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate>
+@interface ReactNativePageView () <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate, UIGestureRecognizerDelegate>
@property(nonatomic, strong) UIPageViewController *reactPageViewController;
@property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
@@ -80,6 +80,10 @@
[self setupInitialController];
}
+ UIPanGestureRecognizer* panGestureRecognizer = [UIPanGestureRecognizer new];
+ panGestureRecognizer.delegate = self;
+ [self addGestureRecognizer: panGestureRecognizer];
+
if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
[self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
}
@@ -463,4 +467,21 @@
- (BOOL)isLtrLayout {
return [_layoutDirection isEqualToString:@"ltr"];
}
+
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
+ if (otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
+ UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
+ CGPoint velocity = [p velocityInView:self];
+ if (self.currentIndex == 0 && velocity.x > 0) {
+ self.scrollView.panGestureRecognizer.enabled = false;
+ return NO;
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+
+ return YES;
+}
@end

View File

@ -4,8 +4,10 @@ import {Linking} from 'react-native'
import {RootSiblingParent} from 'react-native-root-siblings'
import SplashScreen from 'react-native-splash-screen'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {observer} from 'mobx-react-lite'
import {ThemeProvider} from 'lib/ThemeContext'
import {s} from 'lib/styles'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {Shell} from './view/shell'
@ -51,9 +53,11 @@ const App = observer(() => {
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<GestureHandlerRootView style={s.h100pct}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</GestureHandlerRootView>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>

View File

@ -1,5 +1,7 @@
import {AppBskyFeedFeedViewPost} from '@atproto/api'
import lande from 'lande'
type FeedViewPost = AppBskyFeedFeedViewPost.Main
import {hasProp} from '@atproto/lexicon'
export type FeedTunerFn = (
tuner: FeedTuner,
@ -140,7 +142,8 @@ export class FeedTuner {
for (const item of slice.items) {
this.seenUris.add(item.post.uri)
}
slice.logSelf()
// DEBUG uncomment to get a quick view of the data
// slice.logSelf()
}
return slices
@ -177,6 +180,33 @@ export class FeedTuner {
}
}
}
static englishOnly(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
// TEMP
// remove slices with no english in them
// we very soon need to get the local user's language and filter
// according to their preferences, but for the moment
// we're just rolling with english
// -prf
for (let i = slices.length - 1; i >= 0; i--) {
let hasEnglish = false
for (const item of slices[i].items) {
if (
hasProp(item.post.record, 'text') &&
typeof item.post.record.text === 'string'
) {
const res = lande(item.post.record.text)
if (res[0][0] === 'eng') {
hasEnglish = true
break
}
}
}
if (!hasEnglish) {
slices.splice(i, 1)
}
}
}
}
function getSelfReplyUri(item: FeedViewPost): string | undefined {

View File

@ -4,6 +4,7 @@ import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext'
export interface UsePaletteValue {
colors: PaletteColor
view: ViewStyle
viewLight: ViewStyle
btn: ViewStyle
border: ViewStyle
borderDark: ViewStyle
@ -20,6 +21,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
view: {
backgroundColor: palette.background,
},
viewLight: {
backgroundColor: palette.backgroundLight,
},
btn: {
backgroundColor: palette.backgroundLight,
},

View File

@ -70,6 +70,7 @@ export const s = StyleSheet.create({
borderRight1: {borderRightWidth: 1},
borderBottom1: {borderBottomWidth: 1},
borderLeft1: {borderLeftWidth: 1},
hidden: {display: 'none'},
// font weights
fw600: {fontWeight: '600'},

View File

@ -0,0 +1,110 @@
import {AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import sampleSize from 'lodash.samplesize'
import {bundleAsync} from 'lib/async/bundle'
import {RootStoreModel} from '../root-store'
export type RefWithInfoAndFollowers = AppBskyActorRef.WithInfo & {
followers: AppBskyActorProfile.View[]
}
export type ProfileViewFollows = AppBskyActorProfile.View & {
follows: AppBskyActorRef.WithInfo[]
}
export class FoafsModel {
isLoading = false
hasData = false
sources: string[] = []
foafs: Map<string, ProfileViewFollows> = new Map()
popular: RefWithInfoAndFollowers[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
}
get hasContent() {
if (this.popular.length > 0) {
return true
}
for (const foaf of this.foafs.values()) {
if (foaf.follows.length) {
return true
}
}
return false
}
fetch = bundleAsync(async () => {
try {
this.isLoading = true
await this.rootStore.me.follows.fetchIfNeeded()
// grab 10 of the users followed by the user
this.sources = sampleSize(
Object.keys(this.rootStore.me.follows.followDidToRecordMap),
10,
)
if (this.sources.length === 0) {
return
}
this.foafs.clear()
this.popular.length = 0
// fetch their profiles
const profiles = await this.rootStore.api.app.bsky.actor.getProfiles({
actors: this.sources,
})
// fetch their follows
const results = await Promise.allSettled(
this.sources.map(source =>
this.rootStore.api.app.bsky.graph.getFollows({user: source}),
),
)
// store the follows and construct a "most followed" set
const popular: RefWithInfoAndFollowers[] = []
for (let i = 0; i < results.length; i++) {
const res = results[i]
const profile = profiles.data.profiles[i]
const source = this.sources[i]
if (res.status === 'fulfilled' && profile) {
// filter out users already followed by the user or that *is* the user
res.value.data.follows = res.value.data.follows.filter(follow => {
return (
follow.did !== this.rootStore.me.did &&
!this.rootStore.me.follows.isFollowing(follow.did)
)
})
runInAction(() => {
this.foafs.set(source, {
...profile,
follows: res.value.data.follows,
})
})
for (const follow of res.value.data.follows) {
let item = popular.find(p => p.did === follow.did)
if (!item) {
item = {...follow, followers: []}
popular.push(item)
}
item.followers.push(profile)
}
}
}
popular.sort((a, b) => b.followers.length - a.followers.length)
runInAction(() => {
this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20)
})
this.hasData = true
} catch (e) {
console.error('Failed to fetch FOAFs', e)
} finally {
runInAction(() => {
this.isLoading = false
})
}
})
}

View File

@ -257,7 +257,7 @@ export class FeedModel {
constructor(
public rootStore: RootStoreModel,
public feedType: 'home' | 'author' | 'suggested',
public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
) {
makeAutoObservable(
@ -336,6 +336,20 @@ export class FeedModel {
return this.setup()
}
private get feedTuners() {
if (this.feedType === 'goodstuff') {
return [
FeedTuner.dedupReposts,
FeedTuner.likedRepliesOnly,
FeedTuner.englishOnly,
]
}
if (this.feedType === 'home') {
return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
}
return []
}
/**
* Load for first render
*/
@ -399,6 +413,7 @@ export class FeedModel {
params: this.params,
e,
})
this.hasMore = false
}
} finally {
this.lock.release()
@ -476,7 +491,8 @@ export class FeedModel {
}
const res = await this._getFeed({limit: 1})
const currentLatestUri = this.pollCursor
const item = res.data.feed[0]
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
const item = slices[0]?.rootItem
if (!item) {
return
}
@ -541,12 +557,7 @@ export class FeedModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
const slices = this.tuner.tune(
res.data.feed,
this.feedType === 'home'
? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
: [],
)
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
const toAppend: FeedSliceModel[] = []
for (const slice of slices) {
@ -571,12 +582,7 @@ export class FeedModel {
) {
this.pollCursor = res.data.feed[0]?.post.uri
const slices = this.tuner.tune(
res.data.feed,
this.feedType === 'home'
? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
: [],
)
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
const toPrepend: FeedSliceModel[] = []
for (const slice of slices) {
@ -634,6 +640,15 @@ export class FeedModel {
return this.rootStore.api.app.bsky.feed.getTimeline(
params as GetTimeline.QueryParams,
)
} else if (this.feedType === 'goodstuff') {
const res = await getGoodStuff(
this.rootStore.session.currentSession?.accessJwt || '',
params as GetTimeline.QueryParams,
)
res.data.feed = (res.data.feed || []).filter(
item => !item.post.author.viewer?.muted,
)
return res
} else {
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
params as GetAuthorFeed.QueryParams,
@ -641,3 +656,45 @@ export class FeedModel {
}
}
}
// HACK
// temporary off-spec route to get the good stuff
// -prf
async function getGoodStuff(
accessJwt: string,
params: GetTimeline.QueryParams,
): Promise<GetTimeline.Response> {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 15e3)
const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
let k: keyof GetTimeline.QueryParams
for (k in params) {
if (typeof params[k] !== 'undefined') {
uri.searchParams.set(k, String(params[k]))
}
}
const res = await fetch(String(uri), {
method: 'get',
headers: {
accept: 'application/json',
authorization: `Bearer ${accessJwt}`,
},
signal: controller.signal,
})
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
let resBody = await res.json()
clearTimeout(to)
return {
success: res.status === 200,
headers: resHeaders,
data: resBody,
}
}

View File

@ -33,6 +33,7 @@ export class MeModel {
clear() {
this.mainFeed.clear()
this.notifications.clear()
this.follows.clear()
this.did = ''
this.handle = ''
this.displayName = ''

View File

@ -35,6 +35,12 @@ export class MyFollowsModel {
// public api
// =
clear() {
this.followDidToRecordMap = {}
this.lastSync = 0
this.myDid = undefined
}
fetchIfNeeded = bundleAsync(async () => {
if (
this.myDid !== this.rootStore.me.did ||

View File

@ -154,13 +154,13 @@ export class SessionModel {
/**
* Sets the active session
*/
setActiveSession(agent: AtpAgent, did: string) {
async setActiveSession(agent: AtpAgent, did: string) {
this._log('SessionModel:setActiveSession')
this.data = {
service: agent.service.toString(),
did,
}
this.rootStore.handleSessionChange(agent)
await this.rootStore.handleSessionChange(agent)
}
/**
@ -304,7 +304,7 @@ export class SessionModel {
return false
}
this.setActiveSession(agent, account.did)
await this.setActiveSession(agent, account.did)
return true
}
@ -337,7 +337,7 @@ export class SessionModel {
},
)
this.setActiveSession(agent, did)
await this.setActiveSession(agent, did)
this._log('SessionModel:login succeeded')
}
@ -376,8 +376,7 @@ export class SessionModel {
},
)
this.setActiveSession(agent, did)
this.rootStore.shell.setOnboarding(true)
await this.setActiveSession(agent, did)
this._log('SessionModel:createAccount succeeded')
}

View File

@ -122,13 +122,13 @@ export class ShellUiModel {
darkMode = false
minimalShellMode = false
isDrawerOpen = false
isDrawerSwipeDisabled = false
isModalActive = false
activeModals: Modal[] = []
isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
isComposerActive = false
composerOpts: ComposerOpts | undefined
isOnboarding = false
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
@ -168,6 +168,10 @@ export class ShellUiModel {
this.isDrawerOpen = false
}
setIsDrawerSwipeDisabled(v: boolean) {
this.isDrawerSwipeDisabled = v
}
openModal(modal: Modal) {
this.rootStore.emitNavigation()
this.isModalActive = true
@ -200,13 +204,4 @@ export class ShellUiModel {
this.isComposerActive = false
this.composerOpts = undefined
}
setOnboarding(v: boolean) {
this.isOnboarding = v
if (this.isOnboarding) {
this.rootStore.me.mainFeed.switchFeedType('suggested')
} else {
this.rootStore.me.mainFeed.switchFeedType('home')
}
}
}

View File

@ -1,116 +1,68 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {observer} from 'mobx-react-lite'
import {ErrorScreen} from '../util/error/ErrorScreen'
import {StyleSheet, View} from 'react-native'
import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from 'state/models/suggested-actors-view'
import {s} from 'lib/styles'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedFollows = observer(
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[store],
)
React.useEffect(() => {
view
.loadMore()
.catch((err: any) =>
store.log.error('Failed to fetch suggestions', err),
)
}, [view, store.log])
React.useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.()
}
}, [view, view.isLoading, view.hasError, view.hasContent, onNoSuggestions])
const onRefresh = () => {
view
.refresh()
.catch((err: any) =>
store.log.error('Failed to fetch suggestions', err),
)
}
const onEndReached = () => {
view
.loadMore()
.catch(err =>
view?.rootStore.log.error('Failed to load more suggestions', err),
)
}
const renderItem = ({item}: {item: SuggestedActor}) => {
return (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
)
}
return (
<View style={styles.container}>
{view.hasError ? (
<CenteredView>
<ErrorScreen
title="Failed to load suggestions"
message="There was an error while trying to load suggested follows."
details={view.error}
onPressTryAgain={onRefresh}
/>
</CenteredView>
) : view.isEmpty ? (
<View />
) : (
<View style={[styles.suggestionsContainer, pal.view]}>
<FlatList
data={view.suggestions}
keyExtractor={item => item.did}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
ListFooterComponent={() => (
<View style={styles.footer}>
{view.isLoading && <ActivityIndicator />}
</View>
)}
contentContainerStyle={s.contentContainer}
/>
</View>
)}
</View>
)
},
)
export const SuggestedFollows = ({
title,
suggestions,
}: {
title: string
suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[]
}) => {
const pal = usePalette('default')
return (
<View style={[styles.container, pal.view]}>
<Text type="title" style={[styles.heading, pal.text]}>
{title}
</Text>
{suggestions.map(item => (
<View key={item.did} style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
noBg
noBorder
description=""
followers={
item.followers
? (item.followers as AppBskyActorProfile.View[])
: undefined
}
/>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
container: {
height: '100%',
paddingVertical: 10,
paddingHorizontal: 4,
},
suggestionsContainer: {
height: '100%',
heading: {
fontWeight: 'bold',
paddingHorizontal: 4,
paddingBottom: 8,
},
footer: {
height: 200,
paddingTop: 20,
card: {
borderRadius: 12,
marginBottom: 2,
borderWidth: 1,
},
loadMore: {
paddingLeft: 16,
paddingVertical: 12,
},
})

View File

@ -7,23 +7,17 @@ import {
StyleSheet,
ViewStyle,
} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
import {CenteredView, FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {ViewHeader} from '../util/ViewHeader'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Button} from '../util/forms/Button'
import {FeedModel} from 'state/models/feed-view'
import {FeedSlice} from './FeedSlice'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
const HEADER_ITEM = {_reactKey: '__header__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
@ -34,6 +28,7 @@ export const Feed = observer(function Feed({
scrollElRef,
onPressTryAgain,
onScroll,
renderEmptyState,
testID,
headerOffset = 0,
}: {
@ -43,17 +38,15 @@ export const Feed = observer(function Feed({
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
renderEmptyState?: () => JSX.Element
testID?: string
headerOffset?: number
}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const navigation = useNavigation<NavigationProp>()
const data = React.useMemo(() => {
let feedItems: any[] = []
let feedItems: any[] = [HEADER_ITEM]
if (feed.hasLoaded) {
if (feed.hasError) {
feedItems = feedItems.concat([ERROR_FEED_ITEM])
@ -80,6 +73,7 @@ export const Feed = observer(function Feed({
}
setIsRefreshing(false)
}, [feed, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Feed:onEndReached')
try {
@ -95,37 +89,10 @@ export const Feed = observer(function Feed({
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === EMPTY_FEED_ITEM) {
return (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MagnifyingGlassIcon
style={[styles.emptyIcon, pal.text]}
size={62}
/>
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your feed is empty! You should follow some accounts to fix this.
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={
() =>
navigation.navigate(
'SearchTab',
) /* TODO make sure it goes to root of the tab */
}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
)
if (renderEmptyState) {
return renderEmptyState()
}
return <View />
} else if (item === ERROR_FEED_ITEM) {
return (
<ErrorMessage
@ -133,10 +100,12 @@ export const Feed = observer(function Feed({
onPressTryAgain={onPressTryAgain}
/>
)
} else if (item === HEADER_ITEM) {
return <ViewHeader title="Bluesky" canGoBack={false} />
}
return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
},
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
[feed, onPressTryAgain, showPostFollowBtn, renderEmptyState],
)
const FeedFooter = React.useCallback(
@ -183,21 +152,4 @@ export const Feed = observer(function Feed({
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
emptyContainer: {
paddingVertical: 40,
paddingHorizontal: 30,
},
emptyIconContainer: {
marginBottom: 16,
},
emptyIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
emptyBtn: {
marginTop: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
})

View File

@ -0,0 +1,81 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {Button} from '../util/forms/Button'
import {MagnifyingGlassIcon} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
export function FollowingEmptyState() {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const navigation = useNavigation<NavigationProp>()
const onPressFindAccounts = React.useCallback(() => {
navigation.navigate('SearchTab')
navigation.popToTop()
}, [navigation])
return (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your following feed is empty! Find some accounts to follow to fix this.
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
)
}
const styles = StyleSheet.create({
emptyContainer: {
// flex: 1,
height: '100%',
paddingVertical: 40,
paddingHorizontal: 30,
},
emptyIconContainer: {
marginBottom: 16,
},
emptyIcon: {
marginLeft: 'auto',
marginRight: 'auto',
},
emptyBtn: {
marginVertical: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 18,
paddingHorizontal: 24,
borderRadius: 30,
},
feedsTip: {
position: 'absolute',
left: 22,
},
feedsTipArrow: {
marginLeft: 32,
marginTop: 8,
},
})

View File

@ -1,16 +1,18 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {Button} from '../util/forms/Button'
import {Button, ButtonType} from '../util/forms/Button'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import * as Toast from '../util/Toast'
const FollowButton = observer(
({
type = 'inverted',
did,
declarationCid,
onToggleFollow,
}: {
type?: ButtonType
did: string
declarationCid: string
onToggleFollow?: (v: boolean) => void
@ -42,7 +44,7 @@ const FollowButton = observer(
return (
<Button
type={isFollowing ? 'default' : 'primary'}
type={isFollowing ? 'default' : type}
onPress={onToggleFollowInner}
label={isFollowing ? 'Unfollow' : 'Follow'}
/>

View File

@ -1,6 +1,7 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorProfile} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
@ -15,7 +16,9 @@ export function ProfileCard({
avatar,
description,
isFollowedBy,
noBg,
noBorder,
followers,
renderButton,
}: {
handle: string
@ -23,7 +26,9 @@ export function ProfileCard({
avatar?: string
description?: string
isFollowedBy?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
@ -31,9 +36,9 @@ export function ProfileCard({
<Link
style={[
styles.outer,
pal.view,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
]}
href={`/profile/${handle}`}
title={handle}
@ -73,6 +78,25 @@ export function ProfileCard({
</Text>
</View>
) : undefined}
{followers?.length ? (
<View style={styles.followedBy}>
<Text
type="sm"
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followers.map(f => f.displayName || f.handle).join(', ')}
</Text>
{followers.slice(0, 3).map(f => (
<View key={f.did} style={styles.followedByAviContainer}>
<View style={[styles.followedByAvi, pal.view]}>
<UserAvatar avatar={f.avatar} size={32} />
</View>
</View>
))}
</View>
) : undefined}
</Link>
)
}
@ -86,6 +110,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar,
description,
isFollowedBy,
noBg,
noBorder,
followers,
}: {
did: string
declarationCid: string
@ -94,6 +121,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar?: string
description?: string
isFollowedBy?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorProfile.View[] | undefined
}) => {
const store = useStores()
const isMe = store.me.handle === handle
@ -105,6 +135,9 @@ export const ProfileCardWithFollowBtn = observer(
avatar={avatar}
description={description}
isFollowedBy={isFollowedBy}
noBg={noBg}
noBorder={noBorder}
followers={followers}
renderButton={
isMe
? undefined
@ -128,8 +161,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
width: 54,
paddingLeft: 4,
paddingTop: 8,
paddingBottom: 10,
},
@ -164,4 +197,27 @@ const styles = StyleSheet.create({
marginLeft: 6,
paddingHorizontal: 14,
},
followedBy: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingLeft: 54,
paddingRight: 20,
marginBottom: 10,
marginTop: -6,
},
followedByAviContainer: {
width: 24,
height: 36,
},
followedByAvi: {
width: 36,
height: 36,
borderRadius: 18,
padding: 2,
},
followsByDesc: {
flex: 1,
paddingRight: 10,
},
})

View File

@ -128,6 +128,46 @@ export function NotificationFeedLoadingPlaceholder() {
)
}
export function ProfileCardLoadingPlaceholder({
style,
}: {
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
return (
<View style={[styles.profileCard, pal.view, style]}>
<LoadingPlaceholder
width={40}
height={40}
style={styles.profileCardAvi}
/>
<View>
<LoadingPlaceholder width={140} height={8} style={[s.mb5]} />
<LoadingPlaceholder width={120} height={8} style={[s.mb10]} />
<LoadingPlaceholder width={220} height={8} style={[s.mb5]} />
</View>
</View>
)
}
export function ProfileCardFeedLoadingPlaceholder() {
return (
<>
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
</>
)
}
const styles = StyleSheet.create({
loadingPlaceholder: {
borderRadius: 6,
@ -147,6 +187,15 @@ const styles = StyleSheet.create({
paddingLeft: 46,
margin: 1,
},
profileCard: {
flexDirection: 'row',
padding: 10,
margin: 1,
},
profileCardAvi: {
borderRadius: 20,
marginRight: 10,
},
smallAvatar: {
borderRadius: 15,
marginRight: 10,

View File

@ -44,7 +44,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
// two-liner with follow button
return (
<View style={styles.metaTwoLine}>
<View>
<View style={styles.metaTwoLineLeft}>
<View style={styles.metaTwoLineTop}>
<DesktopWebTextLink
type="lg-bold"
@ -69,6 +69,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
type="md"
style={[styles.metaItem, pal.textLight]}
lineHeight={1.2}
numberOfLines={1}
text={`@${handle}`}
href={`/profile/${opts.authorHandle}`}
/>
@ -76,6 +77,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
<View>
<FollowButton
type="default"
did={opts.did}
declarationCid={opts.declarationCid}
onToggleFollow={onToggleFollow}
@ -134,7 +136,12 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 2,
width: '100%',
paddingBottom: 4,
},
metaTwoLineLeft: {
flex: 1,
paddingRight: 40,
},
metaTwoLineTop: {
flexDirection: 'row',

View File

@ -0,0 +1,162 @@
import React, {createRef, useState, useMemo} from 'react'
import {
Animated,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {isDesktopWeb} from 'platform/detection'
interface Layout {
x: number
width: number
}
export interface TabBarProps {
selectedPage: number
items: string[]
position: Animated.Value
offset: Animated.Value
indicatorPosition?: 'top' | 'bottom'
indicatorColor?: string
onSelect?: (index: number) => void
onPressSelected?: () => void
}
export function TabBar({
selectedPage,
items,
position,
offset,
indicatorPosition = 'bottom',
indicatorColor,
onSelect,
onPressSelected,
}: TabBarProps) {
const pal = usePalette('default')
const [itemLayouts, setItemLayouts] = useState<Layout[]>(
items.map(() => ({x: 0, width: 0})),
)
const itemRefs = useMemo(
() => Array.from({length: items.length}).map(() => createRef<View>()),
[items.length],
)
const panX = Animated.add(position, offset)
const indicatorStyle = {
backgroundColor: indicatorColor || pal.colors.link,
bottom:
indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
transform: [
{
translateX: panX.interpolate({
inputRange: items.map((_item, i) => i),
outputRange: itemLayouts.map(l => l.x + l.width / 2),
}),
},
{
scaleX: panX.interpolate({
inputRange: items.map((_item, i) => i),
outputRange: itemLayouts.map(l => l.width),
}),
},
],
}
const onLayout = () => {
const promises = []
for (let i = 0; i < items.length; i++) {
promises.push(
new Promise<Layout>(resolve => {
itemRefs[i].current?.measure(
(x: number, _y: number, width: number) => {
resolve({x, width})
},
)
}),
)
}
Promise.all(promises).then((layouts: Layout[]) => {
setItemLayouts(layouts)
})
}
const onPressItem = (index: number) => {
onSelect?.(index)
if (index === selectedPage) {
onPressSelected?.()
}
}
return (
<View style={[pal.view, styles.outer]} onLayout={onLayout}>
<Animated.View style={[styles.indicator, indicatorStyle]} />
{items.map((item, i) => {
const selected = i === selectedPage
return (
<TouchableWithoutFeedback key={i} onPress={() => onPressItem(i)}>
<View
style={
indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
}
ref={itemRefs[i]}>
<Text type="xl-bold" style={selected ? pal.text : pal.textLight}>
{item}
</Text>
</View>
</TouchableWithoutFeedback>
)
})}
</View>
)
}
const styles = isDesktopWeb
? StyleSheet.create({
outer: {
flexDirection: 'row',
paddingHorizontal: 18,
},
itemTop: {
paddingTop: 16,
paddingBottom: 14,
marginRight: 24,
},
itemBottom: {
paddingTop: 14,
paddingBottom: 16,
marginRight: 24,
},
indicator: {
position: 'absolute',
left: 0,
width: 1,
height: 3,
},
})
: StyleSheet.create({
outer: {
flexDirection: 'row',
paddingHorizontal: 14,
},
itemTop: {
paddingTop: 10,
paddingBottom: 10,
marginRight: 24,
},
itemBottom: {
paddingTop: 8,
paddingBottom: 12,
marginRight: 24,
},
indicator: {
position: 'absolute',
left: 0,
width: 1,
height: 3,
borderRadius: 4,
},
})

View File

@ -1,101 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from './text/Text'
import {Button} from './forms/Button'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
// @ts-ignore no type definition -prf
import ProgressBar from 'react-native-progress/Bar'
import {CenteredView} from './Views'
export const WelcomeBanner = observer(() => {
const pal = usePalette('default')
const store = useStores()
const [isReady, setIsReady] = React.useState(false)
const numFollows = Math.min(
SUGGESTED_FOLLOWS(String(store.agent.service)).length,
5,
)
const remaining = numFollows - store.me.follows.numFollows
React.useEffect(() => {
if (remaining <= 0) {
// wait 500ms for the progress bar anim to finish
const ti = setTimeout(() => {
setIsReady(true)
}, 500)
return () => clearTimeout(ti)
} else {
setIsReady(false)
}
}, [remaining])
const onPressDone = React.useCallback(() => {
store.shell.setOnboarding(false)
}, [store])
return (
<CenteredView
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 Bluesky!
</Text>
{isReady ? (
<View style={styles.controls}>
<Button
type="primary"
style={[s.flexRow, s.alignCenter]}
onPress={onPressDone}>
<Text type="md-bold" style={s.white}>
See my feed!
</Text>
<FontAwesomeIcon icon="angle-right" size={14} style={s.white} />
</Button>
</View>
) : (
<>
<Text type="lg" style={[pal.text, s.textCenter]}>
Follow at least {remaining} {remaining === 1 ? 'person' : 'people'}{' '}
to build your feed.
</Text>
<View style={[styles.controls, styles.progress]}>
<ProgressBar
progress={Math.max(
store.me.follows.numFollows / numFollows,
0.05,
)}
/>
</View>
</>
)}
</CenteredView>
)
})
const styles = StyleSheet.create({
container: {
paddingTop: 16,
paddingBottom: 16,
paddingHorizontal: 20,
borderTopWidth: 1,
borderBottomWidth: 1,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
},
progress: {
marginTop: 12,
},
})

View File

@ -0,0 +1,87 @@
import React from 'react'
import {Animated, View} from 'react-native'
import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles'
export type PageSelectedEvent = PagerViewOnPageSelectedEvent
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export interface RenderTabBarFnProps {
selectedPage: number
position: Animated.Value
offset: Animated.Value
onSelect?: (index: number) => void
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
tabBarPosition?: 'top' | 'bottom'
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = ({
children,
tabBarPosition = 'top',
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) => {
const [selectedPage, setSelectedPage] = React.useState(0)
const position = useAnimatedValue(0)
const offset = useAnimatedValue(0)
const pagerView = React.useRef<PagerView>()
const onPageSelectedInner = React.useCallback(
(e: PageSelectedEvent) => {
setSelectedPage(e.nativeEvent.position)
onPageSelected?.(e.nativeEvent.position)
},
[setSelectedPage, onPageSelected],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
},
[pagerView],
)
return (
<View>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
<AnimatedPagerView
ref={pagerView}
style={s.h100pct}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageScroll={Animated.event(
[
{
nativeEvent: {
position: position,
offset: offset,
},
},
],
{useNativeDriver: true},
)}>
{children}
</AnimatedPagerView>
{tabBarPosition === 'bottom' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
</View>
)
}

View File

@ -0,0 +1,69 @@
import React from 'react'
import {Animated, View} from 'react-native'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {s} from 'lib/styles'
export interface RenderTabBarFnProps {
selectedPage: number
position: Animated.Value
offset: Animated.Value
onSelect?: (index: number) => void
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
tabBarPosition?: 'top' | 'bottom'
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = ({
children,
tabBarPosition = 'top',
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) => {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const position = useAnimatedValue(0)
const offset = useAnimatedValue(0)
const onTabBarSelect = React.useCallback(
(index: number) => {
setSelectedPage(index)
onPageSelected?.(index)
Animated.timing(position, {
toValue: index,
duration: 200,
useNativeDriver: true,
}).start()
},
[setSelectedPage, onPageSelected, position],
)
return (
<View>
{tabBarPosition === 'top' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
{children.map((child, i) => (
<View
style={selectedPage === i ? undefined : s.hidden}
key={`page-${i}`}>
{child}
</View>
))}
{tabBarPosition === 'bottom' &&
renderTabBar({
selectedPage,
position,
offset,
onSelect: onTabBarSelect,
})}
</View>
)
}

View File

@ -1,26 +1,97 @@
import React from 'react'
import {FlatList, View} from 'react-native'
import {FlatList, View, useWindowDimensions} from 'react-native'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {observer} from 'mobx-react-lite'
import useAppState from 'react-native-appstate-hook'
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
import {FeedModel} from 'state/models/feed-view'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ViewHeader} from '../com/util/ViewHeader'
import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
import {WelcomeBanner} from '../com/util/WelcomeBanner'
import {FeedsTabBar} from './home/FeedsTabBar'
import {Pager, RenderTabBarFnProps} from 'view/com/util/pager/Pager'
import {FAB} from '../com/util/FAB'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {useAnalytics} from 'lib/analytics'
import {ComposeIcon2} from 'lib/icons'
import {isDesktopWeb} from 'platform/detection'
const HEADER_HEIGHT = 42
const TAB_BAR_HEIGHT = 82
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired(
observer(function Home(_opts: Props) {
export const HomeScreen = withAuthRequired((_opts: Props) => {
const store = useStores()
const [selectedPage, setSelectedPage] = React.useState(0)
const algoFeed = React.useMemo(() => {
const feed = new FeedModel(store, 'goodstuff', {})
feed.setup()
return feed
}, [store])
useFocusEffect(
React.useCallback(() => {
store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
return () => {
store.shell.setIsDrawerSwipeDisabled(false)
}
}, [store, selectedPage]),
)
const onPageSelected = React.useCallback(
(index: number) => {
setSelectedPage(index)
store.shell.setIsDrawerSwipeDisabled(index > 0)
},
[store],
)
const onPressSelected = React.useCallback(() => {
store.emitScreenSoftReset()
}, [store])
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return <FeedsTabBar {...props} onPressSelected={onPressSelected} />
},
[onPressSelected],
)
const renderFollowingEmptyState = React.useCallback(() => {
return <FollowingEmptyState />
}, [])
const initialPage = store.me.follows.isEmpty ? 1 : 0
return (
<Pager
onPageSelected={onPageSelected}
renderTabBar={renderTabBar}
tabBarPosition={isDesktopWeb ? 'top' : 'bottom'}
initialPage={initialPage}>
<FeedPage
key="1"
isPageFocused={selectedPage === 0}
feed={store.me.mainFeed}
renderEmptyState={renderFollowingEmptyState}
/>
<FeedPage key="2" isPageFocused={selectedPage === 1} feed={algoFeed} />
</Pager>
)
})
const FeedPage = observer(
({
isPageFocused,
feed,
renderEmptyState,
}: {
feed: FeedModel
isPageFocused: boolean
renderEmptyState?: () => JSX.Element
}) => {
const store = useStores()
const onMainScroll = useOnMainScroll(store)
const {screen, track} = useAnalytics()
@ -28,38 +99,51 @@ export const HomeScreen = withAuthRequired(
const {appState} = useAppState({
onForeground: () => doPoll(true),
})
const isFocused = useIsFocused()
const isScreenFocused = useIsFocused()
const winDim = useWindowDimensions()
const containerStyle = React.useMemo(
() => ({height: winDim.height - (isDesktopWeb ? 0 : TAB_BAR_HEIGHT)}),
[winDim],
)
const doPoll = React.useCallback(
(knownActive = false) => {
if ((!knownActive && appState !== 'active') || !isFocused) {
if (
(!knownActive && appState !== 'active') ||
!isScreenFocused ||
!isPageFocused
) {
return
}
if (store.me.mainFeed.isLoading) {
if (feed.isLoading) {
return
}
store.log.debug('HomeScreen: Polling for new posts')
store.me.mainFeed.checkForLatest()
feed.checkForLatest()
},
[appState, isFocused, store],
[appState, isScreenFocused, isPageFocused, store, feed],
)
const scrollToTop = React.useCallback(() => {
// NOTE: the feed is offset by the height of the collapsing header,
// so we scroll to the negative of that height -prf
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
scrollElRef.current?.scrollToOffset({offset: 0})
}, [scrollElRef])
const onSoftReset = React.useCallback(() => {
if (isPageFocused) {
scrollToTop()
}
}, [isPageFocused, scrollToTop])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(scrollToTop)
const feedCleanup = store.me.mainFeed.registerListeners()
const softResetSub = store.onScreenSoftReset(onSoftReset)
const feedCleanup = feed.registerListeners()
const pollInterval = setInterval(doPoll, 15e3)
screen('Feed')
store.log.debug('HomeScreen: Updating feed')
if (store.me.mainFeed.hasContent) {
store.me.mainFeed.update()
if (feed.hasContent) {
feed.update()
}
return () => {
@ -67,7 +151,7 @@ export const HomeScreen = withAuthRequired(
softResetSub.remove()
feedCleanup()
}
}, [store, doPoll, scrollToTop, screen]),
}, [store, doPoll, onSoftReset, screen, feed]),
)
const onPressCompose = React.useCallback(() => {
@ -76,32 +160,28 @@ export const HomeScreen = withAuthRequired(
}, [store, track])
const onPressTryAgain = React.useCallback(() => {
store.me.mainFeed.refresh()
}, [store])
feed.refresh()
}, [feed])
const onPressLoadLatest = React.useCallback(() => {
store.me.mainFeed.refresh()
feed.refresh()
scrollToTop()
}, [store, scrollToTop])
}, [feed, scrollToTop])
return (
<View style={s.hContentRegion}>
{store.shell.isOnboarding && <WelcomeBanner />}
<View style={containerStyle}>
<Feed
testID="homeFeed"
key="default"
feed={store.me.mainFeed}
feed={feed}
scrollElRef={scrollElRef}
style={s.hContentRegion}
showPostFollowBtn
onPressTryAgain={onPressTryAgain}
onScroll={onMainScroll}
headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
renderEmptyState={renderEmptyState}
/>
{!store.shell.isOnboarding && (
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
)}
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
{feed.hasNewLatest && !feed.isRefreshing && (
<LoadLatestBtn onPress={onPressLoadLatest} />
)}
<FAB
@ -111,5 +191,5 @@ export const HomeScreen = withAuthRequired(
/>
</View>
)
}),
},
)

View File

@ -1,6 +1,7 @@
import React from 'react'
import {
Keyboard,
RefreshControl,
StyleSheet,
TextInput,
TouchableOpacity,
@ -13,21 +14,23 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {ScrollView} from '../com/util/Views'
import {ScrollView} from 'view/com/util/Views'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {UserAvatar} from '../com/util/UserAvatar'
import {Text} from '../com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import {useStores} from 'state/index'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {FoafsModel} from 'state/models/discovery/foafs'
import {s} from 'lib/styles'
import {MagnifyingGlassIcon} from 'lib/icons'
import {WhoToFollow} from '../com/discover/WhoToFollow'
import {SuggestedPosts} from '../com/discover/SuggestedPosts'
import {ProfileCard} from '../com/profile/ProfileCard'
import {WhoToFollow} from 'view/com/discover/WhoToFollow'
import {SuggestedFollows} from 'view/com/discover/SuggestedFollows'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
@ -53,6 +56,11 @@ export const SearchScreen = withAuthRequired(
() => new UserAutocompleteViewModel(store),
[store],
)
const foafsView = React.useMemo<FoafsModel>(
() => new FoafsModel(store),
[store],
)
const [refreshing, setRefreshing] = React.useState(false)
const onSoftReset = () => {
scrollElRef.current?.scrollTo({x: 0, y: 0})
@ -71,9 +79,12 @@ export const SearchScreen = withAuthRequired(
}
store.shell.setMinimalShellMode(false)
autocompleteView.setup()
if (!foafsView.hasData) {
foafsView.fetch()
}
return cleanup
}, [store, autocompleteView, lastRenderTime, setRenderTime]),
}, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]),
)
const onPressMenu = () => {
@ -98,15 +109,18 @@ export const SearchScreen = withAuthRequired(
autocompleteView.setActive(false)
textInput.current?.blur()
}
const onRefresh = React.useCallback(async () => {
setRefreshing(true)
try {
await foafsView.fetch()
} finally {
setRefreshing(false)
}
}, [foafsView, setRefreshing])
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={[pal.view, styles.container]}
onScroll={onMainScroll}
scrollEventThrottle={100}>
<View style={[pal.view, styles.container]}>
<View style={[pal.view, pal.border, styles.header]}>
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
@ -180,14 +194,53 @@ export const SearchScreen = withAuthRequired(
</Text>
</View>
) : (
<ScrollView onScroll={Keyboard.dismiss}>
<WhoToFollow key={`wtf-${lastRenderTime}`} />
<SuggestedPosts key={`sp-${lastRenderTime}`} />
<ScrollView
ref={scrollElRef}
testID="searchScrollView"
style={pal.view}
onScroll={onMainScroll}
scrollEventThrottle={100}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}>
{foafsView.isLoading ? (
<ProfileCardFeedLoadingPlaceholder />
) : foafsView.hasContent ? (
<>
{foafsView.popular.length > 0 && (
<View style={styles.suggestions}>
<SuggestedFollows
title="In your network"
suggestions={foafsView.popular}
/>
</View>
)}
{foafsView.sources.map((source, i) => {
const item = foafsView.foafs.get(source)
if (!item || item.follows.length === 0) {
return <View key={`sf-${item?.did || i}`} />
}
return (
<View key={`sf-${item.did}`} style={styles.suggestions}>
<SuggestedFollows
title={`Followed by ${
item.displayName || item.handle
}`}
suggestions={item.follows.slice(0, 10)}
/>
</View>
)
})}
</>
) : (
<View style={pal.view}>
<WhoToFollow />
</View>
)}
<View style={s.footerSpacer} />
</ScrollView>
)}
<View style={s.footerSpacer} />
</ScrollView>
</View>
</TouchableWithoutFeedback>
)
}),
@ -235,4 +288,8 @@ const styles = StyleSheet.create({
textAlign: 'center',
paddingTop: 10,
},
suggestions: {
marginBottom: 8,
},
})

View File

@ -0,0 +1,72 @@
import React from 'react'
import {Animated, StyleSheet} from 'react-native'
import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/util/TabBar'
import {RenderTabBarFnProps} from 'view/com/util/pager/Pager'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {clamp} from 'lodash'
const BOTTOM_BAR_HEIGHT = 48
export const FeedsTabBar = observer(
(props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
const store = useStores()
const safeAreaInsets = useSafeAreaInsets()
const pal = usePalette('default')
const interp = useAnimatedValue(0)
const pad = React.useMemo(
() => ({
paddingBottom: clamp(safeAreaInsets.bottom, 15, 20),
}),
[safeAreaInsets],
)
React.useEffect(() => {
Animated.timing(interp, {
toValue: store.shell.minimalShellMode ? 0 : 1,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [
{translateY: Animated.multiply(interp, -1 * BOTTOM_BAR_HEIGHT)},
],
}
return (
<Animated.View
style={[pal.view, pal.border, styles.tabBar, pad, transform]}>
<TabBar
{...props}
items={['Following', "What's hot"]}
indicatorPosition="top"
indicatorColor={pal.colors.link}
/>
</Animated.View>
)
},
)
const styles = StyleSheet.create({
tabBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
borderTopWidth: 1,
paddingTop: 0,
paddingBottom: 30,
},
tabBarAvi: {
marginRight: 4,
},
})

View File

@ -0,0 +1,22 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {TabBar} from 'view/com/util/TabBar'
import {CenteredView} from 'view/com/util/Views'
import {RenderTabBarFnProps} from 'view/com/util/pager/Pager'
import {usePalette} from 'lib/hooks/usePalette'
export const FeedsTabBar = observer(
(props: RenderTabBarFnProps & {onPressSelected: () => void}) => {
const pal = usePalette('default')
return (
<CenteredView>
<TabBar
{...props}
items={['Following', "What's hot"]}
indicatorPosition="bottom"
indicatorColor={pal.colors.link}
/>
</CenteredView>
)
},
)

View File

@ -34,16 +34,24 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
const minimalShellInterp = useAnimatedValue(0)
const safeAreaInsets = useSafeAreaInsets()
const {track} = useAnalytics()
const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
state => {
return {
const {isAtHome, isAtSearch, isAtNotifications, noBorder} =
useNavigationState(state => {
const res = {
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
isAtNotifications:
getTabState(state, 'Notifications') !== TabState.Outside,
noBorder: getTabState(state, 'Home') === TabState.InsideAtRoot,
}
},
)
if (!res.isAtHome && !res.isAtNotifications && !res.isAtSearch) {
// HACK for some reason useNavigationState will give us pre-hydration results
// and not update after, so we force isAtHome if all came back false
// -prf
res.isAtHome = true
res.noBorder = true
}
return res
})
React.useEffect(() => {
if (store.shell.minimalShellMode) {
@ -99,6 +107,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
<Animated.View
style={[
styles.bottomBar,
noBorder && styles.noBorder,
pal.view,
pal.border,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
@ -213,6 +222,9 @@ const styles = StyleSheet.create({
paddingLeft: 5,
paddingRight: 10,
},
noBorder: {
borderTopWidth: 0,
},
ctrl: {
flex: 1,
paddingTop: 13,

View File

@ -46,7 +46,11 @@ const ShellInner = observer(() => {
onOpen={onOpenDrawer}
onClose={onCloseDrawer}
swipeEdgeWidth={winDim.width}
swipeEnabled={!canGoBack && store.session.hasSession}>
swipeEnabled={
!canGoBack &&
store.session.hasSession &&
!store.shell.isDrawerSwipeDisabled
}>
<TabsNavigator />
</Drawer>
</ErrorBoundary>

708
yarn.lock

File diff suppressed because it is too large Load Diff