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 goodstuffzio/stable
parent
c31ffdac1b
commit
1de724b24b
2
app.json
2
app.json
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
14
package.json
14
package.json
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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}>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
</RootStoreProvider>
|
||||
</analytics.Provider>
|
||||
</RootSiblingParent>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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'},
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export class MeModel {
|
|||
clear() {
|
||||
this.mainFeed.clear()
|
||||
this.notifications.clear()
|
||||
this.follows.clear()
|
||||
this.did = ''
|
||||
this.handle = ''
|
||||
this.displayName = ''
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,58 +1,26 @@
|
|||
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}) => {
|
||||
export const SuggestedFollows = ({
|
||||
title,
|
||||
suggestions,
|
||||
}: {
|
||||
title: string
|
||||
suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[]
|
||||
}) => {
|
||||
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 (
|
||||
<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}
|
||||
|
@ -60,57 +28,41 @@ export const SuggestedFollows = observer(
|
|||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
description={item.description}
|
||||
/>
|
||||
)
|
||||
noBg
|
||||
noBorder
|
||||
description=""
|
||||
followers={
|
||||
item.followers
|
||||
? (item.followers as AppBskyActorProfile.View[])
|
||||
: undefined
|
||||
}
|
||||
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>
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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'}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
|
|
@ -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}`} />
|
||||
<View style={s.footerSpacer} />
|
||||
</ScrollView>
|
||||
<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>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
}),
|
||||
|
@ -235,4 +288,8 @@ const styles = StyleSheet.create({
|
|||
textAlign: 'center',
|
||||
paddingTop: 10,
|
||||
},
|
||||
|
||||
suggestions: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
},
|
||||
)
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue