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": {
|
"expo": {
|
||||||
"name": "bluesky",
|
"name": "bluesky",
|
||||||
"slug": "bluesky",
|
"slug": "bluesky",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
|
|
@ -19,10 +19,10 @@ PODS:
|
||||||
- EXJSONUtils (0.5.1)
|
- EXJSONUtils (0.5.1)
|
||||||
- EXManifests (0.5.2):
|
- EXManifests (0.5.2):
|
||||||
- EXJSONUtils
|
- EXJSONUtils
|
||||||
- EXMediaLibrary (15.2.2):
|
- EXMediaLibrary (15.2.3):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- React-Core
|
- React-Core
|
||||||
- Expo (48.0.6):
|
- Expo (48.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- expo-dev-client (2.1.5):
|
- expo-dev-client (2.1.5):
|
||||||
- EXManifests
|
- EXManifests
|
||||||
|
@ -100,7 +100,7 @@ PODS:
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoKeepAwake (12.0.1):
|
- ExpoKeepAwake (12.0.1):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoModulesCore (1.2.4):
|
- ExpoModulesCore (1.2.5):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-RCTAppDelegate
|
- React-RCTAppDelegate
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
|
@ -384,10 +384,12 @@ PODS:
|
||||||
- glog
|
- glog
|
||||||
- react-native-blur (4.3.0):
|
- react-native-blur (4.3.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-cameraroll (5.2.4):
|
- react-native-cameraroll (5.3.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-image-resizer (3.0.5):
|
- react-native-image-resizer (3.0.5):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
- react-native-pager-view (6.1.2):
|
||||||
|
- React-Core
|
||||||
- react-native-paste-input (0.6.2):
|
- react-native-paste-input (0.6.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
- Swime (= 3.0.6)
|
- Swime (= 3.0.6)
|
||||||
|
@ -401,7 +403,7 @@ PODS:
|
||||||
- React-Core
|
- React-Core
|
||||||
- react-native-version-number (0.3.6):
|
- react-native-version-number (0.3.6):
|
||||||
- React
|
- React
|
||||||
- react-native-webview (11.26.1):
|
- react-native-webview (11.26.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-perflogger (0.71.3)
|
- React-perflogger (0.71.3)
|
||||||
- React-RCTActionSheet (0.71.3):
|
- React-RCTActionSheet (0.71.3):
|
||||||
|
@ -489,9 +491,9 @@ PODS:
|
||||||
- React-perflogger (= 0.71.3)
|
- React-perflogger (= 0.71.3)
|
||||||
- rn-fetch-blob (0.12.0):
|
- rn-fetch-blob (0.12.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNBackgroundFetch (4.1.8):
|
- RNBackgroundFetch (4.1.9):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNCAsyncStorage (1.17.11):
|
- RNCAsyncStorage (1.17.12):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNCClipboard (1.11.2):
|
- RNCClipboard (1.11.2):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -514,10 +516,10 @@ PODS:
|
||||||
- TOCropViewController
|
- TOCropViewController
|
||||||
- RNInAppBrowser (3.7.0):
|
- RNInAppBrowser (3.7.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNNotifee (7.5.0):
|
- RNNotifee (7.6.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNNotifee/NotifeeCore (= 7.5.0)
|
- RNNotifee/NotifeeCore (= 7.6.1)
|
||||||
- RNNotifee/NotifeeCore (7.5.0):
|
- RNNotifee/NotifeeCore (7.6.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReactNativeHapticFeedback (1.14.0):
|
- RNReactNativeHapticFeedback (1.14.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
@ -551,7 +553,7 @@ PODS:
|
||||||
- RNScreens (3.20.0):
|
- RNScreens (3.20.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-RCTImage
|
- React-RCTImage
|
||||||
- RNSVG (13.8.0):
|
- RNSVG (13.4.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- SDWebImage (5.11.1):
|
- SDWebImage (5.11.1):
|
||||||
- SDWebImage/Core (= 5.11.1)
|
- SDWebImage/Core (= 5.11.1)
|
||||||
|
@ -559,7 +561,7 @@ PODS:
|
||||||
- SDWebImageWebPCoder (0.8.5):
|
- SDWebImageWebPCoder (0.8.5):
|
||||||
- libwebp (~> 1.0)
|
- libwebp (~> 1.0)
|
||||||
- SDWebImage/Core (~> 5.10)
|
- SDWebImage/Core (~> 5.10)
|
||||||
- segment-analytics-react-native (2.13.1):
|
- segment-analytics-react-native (2.13.4):
|
||||||
- React-Core
|
- React-Core
|
||||||
- sovran-react-native
|
- sovran-react-native
|
||||||
- sovran-react-native (0.4.5):
|
- sovran-react-native (0.4.5):
|
||||||
|
@ -614,6 +616,7 @@ DEPENDENCIES:
|
||||||
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
|
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
|
||||||
- "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)"
|
- "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-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-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-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||||
- react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
|
- 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"
|
:path: "../node_modules/@react-native-camera-roll/camera-roll"
|
||||||
react-native-image-resizer:
|
react-native-image-resizer:
|
||||||
:path: "../node_modules/@bam.tech/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:
|
react-native-paste-input:
|
||||||
:path: "../node_modules/@mattermost/react-native-paste-input"
|
:path: "../node_modules/@mattermost/react-native-paste-input"
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
|
@ -830,15 +835,15 @@ SPEC CHECKSUMS:
|
||||||
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
|
EXImageLoader: fd053169a8ee932dd83bf1fe5487a50c26d27c2b
|
||||||
EXJSONUtils: 48b1e764ac35160e6f54d21ab60d7d9501f3e473
|
EXJSONUtils: 48b1e764ac35160e6f54d21ab60d7d9501f3e473
|
||||||
EXManifests: 500666d48e8dd7ca5a482c9e729e4a7a6c34081b
|
EXManifests: 500666d48e8dd7ca5a482c9e729e4a7a6c34081b
|
||||||
EXMediaLibrary: 792fe9b828b5bfa2c5a8b629730f175af2938285
|
EXMediaLibrary: 587cd8aad27a6fc8d7c38b950bc75bc1845a7480
|
||||||
Expo: 04ba1ddde0be07aff4306ae636a1804810679145
|
Expo: 707f9b0039eacc6a1dce90c08c9e37b9c417bba2
|
||||||
expo-dev-client: 7c1ef51516853465f4d448c14ddf365167d20361
|
expo-dev-client: 7c1ef51516853465f4d448c14ddf365167d20361
|
||||||
expo-dev-launcher: 90de99d9e5d1a883d81355ca10e87c2f3c81d46e
|
expo-dev-launcher: 90de99d9e5d1a883d81355ca10e87c2f3c81d46e
|
||||||
expo-dev-menu: d4369e74d8d21a0ccdee35f7c732e7118b0fee16
|
expo-dev-menu: d4369e74d8d21a0ccdee35f7c732e7118b0fee16
|
||||||
expo-dev-menu-interface: 6c82ae323c4b8724dead4763ce3ff24a2108bdb1
|
expo-dev-menu-interface: 6c82ae323c4b8724dead4763ce3ff24a2108bdb1
|
||||||
ExpoImagePicker: 270dea232b3a072d981dd564e2cafc63a864edb1
|
ExpoImagePicker: 270dea232b3a072d981dd564e2cafc63a864edb1
|
||||||
ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a
|
ExpoKeepAwake: 69f5f627670d62318410392d03e0b5db0f85759a
|
||||||
ExpoModulesCore: 1667335d4f4c9b7801990930e6f0eea42c916a21
|
ExpoModulesCore: 397fc99e9d6c9dcc010f36d5802097c17b90424c
|
||||||
EXSplashScreen: cd7fb052dff5ba8311d5c2455ecbebffe1b7a8ca
|
EXSplashScreen: cd7fb052dff5ba8311d5c2455ecbebffe1b7a8ca
|
||||||
EXUpdatesInterface: dd699d1930e28639dcbd70a402caea98e86364ca
|
EXUpdatesInterface: dd699d1930e28639dcbd70a402caea98e86364ca
|
||||||
FBLazyVector: 60195509584153283780abdac5569feffb8f08cc
|
FBLazyVector: 60195509584153283780abdac5569feffb8f08cc
|
||||||
|
@ -863,13 +868,14 @@ SPEC CHECKSUMS:
|
||||||
React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd
|
React-jsinspector: 9f7c9137605e72ca0343db4cea88006cb94856dd
|
||||||
React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207
|
React-logger: 957e5dc96d9dbffc6e0f15e0ee4d2b42829ff207
|
||||||
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3
|
||||||
react-native-cameraroll: cb752fda6d5268f1646b4390bd5be1f27706b9a0
|
react-native-cameraroll: f3050460fe1708378698c16686bfaa5f34099be2
|
||||||
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa
|
||||||
|
react-native-pager-view: 54bed894cecebe28cede54c01038d9d1e122de43
|
||||||
react-native-paste-input: 3392800944a47c00dddbff23c31c281482209679
|
react-native-paste-input: 3392800944a47c00dddbff23c31c281482209679
|
||||||
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
|
react-native-safe-area-context: 39c2d8be3328df5d437ac1700f4f3a4f75716acc
|
||||||
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
|
react-native-splash-screen: 4312f786b13a81b5169ef346d76d33bc0c6dc457
|
||||||
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
|
react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f
|
||||||
react-native-webview: 9f111dfbcfc826084d6c507f569e5e03342ee1c1
|
react-native-webview: 994b9f8fbb504d6314dc40d83f94f27c6831b3bf
|
||||||
React-perflogger: af8a3d31546077f42d729b949925cc4549f14def
|
React-perflogger: af8a3d31546077f42d729b949925cc4549f14def
|
||||||
React-RCTActionSheet: 57cc5adfefbaaf0aae2cf7e10bccd746f2903673
|
React-RCTActionSheet: 57cc5adfefbaaf0aae2cf7e10bccd746f2903673
|
||||||
React-RCTAnimation: 11c61e94da700c4dc915cf134513764d87fc5e2b
|
React-RCTAnimation: 11c61e94da700c4dc915cf134513764d87fc5e2b
|
||||||
|
@ -884,22 +890,22 @@ SPEC CHECKSUMS:
|
||||||
React-runtimeexecutor: 7bf0dafc7b727d93c8cb94eb00a9d3753c446c3e
|
React-runtimeexecutor: 7bf0dafc7b727d93c8cb94eb00a9d3753c446c3e
|
||||||
ReactCommon: 6f65ea5b7d84deb9e386f670dd11ce499ded7b40
|
ReactCommon: 6f65ea5b7d84deb9e386f670dd11ce499ded7b40
|
||||||
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba
|
||||||
RNBackgroundFetch: 8e16176ff415daac743a6eb57afc8e9e14dbe623
|
RNBackgroundFetch: 642777e4e76435773c149d565a043d66f1781237
|
||||||
RNCAsyncStorage: 8616bd5a58af409453ea4e1b246521bb76578d60
|
RNCAsyncStorage: 09fc8595e6d6f6d5abf16b23a56b257d9c6b7c5b
|
||||||
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
||||||
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8
|
||||||
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
|
||||||
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
RNGestureHandler: 071d7a9ad81e8b83fe7663b303d132406a7d8f39
|
||||||
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
|
||||||
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
|
||||||
RNNotifee: 053c0ace9c73634709a0214fd9c436a5777a562f
|
RNNotifee: bdc064c29f4d558046f51f0c3ae02bab4fd3cd85
|
||||||
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
|
||||||
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
RNReanimated: cc5e3aa479cb9170bcccf8204291a6950a3be128
|
||||||
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
RNScreens: 218801c16a2782546d30bd2026bb625c0302d70f
|
||||||
RNSVG: c1e76b81c76cdcd34b4e1188852892dc280eb902
|
RNSVG: 07dbd870b0dcdecc99b3a202fa37c8ca163caec2
|
||||||
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
|
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
|
||||||
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
|
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
|
||||||
segment-analytics-react-native: f962dff3a084655a29f9403b8c139c75a3362524
|
segment-analytics-react-native: cc12d9422f7ce863ee57c1b650ab48eec4b6d5bd
|
||||||
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
|
sovran-react-native: fd3dc8f1a4b14acdc4ad25fc6b4ac4f52a2a2a15
|
||||||
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
|
Swime: d7b2c277503b6cea317774aedc2dce05613f8b0b
|
||||||
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
|
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.7</string>
|
<string>1.8</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|
14
package.json
14
package.json
|
@ -1,8 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "patch-package",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
|
@ -58,12 +59,13 @@
|
||||||
"expo-camera": "~13.2.1",
|
"expo-camera": "~13.2.1",
|
||||||
"expo-dev-client": "~2.1.1",
|
"expo-dev-client": "~2.1.1",
|
||||||
"expo-image-picker": "~14.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-splash-screen": "~0.18.1",
|
||||||
"expo-status-bar": "~1.4.4",
|
"expo-status-bar": "~1.4.4",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
|
"lande": "^1.0.10",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
|
@ -75,6 +77,8 @@
|
||||||
"mobx": "^6.6.1",
|
"mobx": "^6.6.1",
|
||||||
"mobx-react-lite": "^3.4.0",
|
"mobx-react-lite": "^3.4.0",
|
||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
|
"patch-package": "^6.5.1",
|
||||||
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
"react-avatar-editor": "^13.0.0",
|
||||||
"react-circular-progressbar": "^2.1.0",
|
"react-circular-progressbar": "^2.1.0",
|
||||||
|
@ -90,21 +94,21 @@
|
||||||
"react-native-image-crop-picker": "^0.38.1",
|
"react-native-image-crop-picker": "^0.38.1",
|
||||||
"react-native-inappbrowser-reborn": "^3.6.3",
|
"react-native-inappbrowser-reborn": "^3.6.3",
|
||||||
"react-native-linear-gradient": "^2.6.2",
|
"react-native-linear-gradient": "^2.6.2",
|
||||||
|
"react-native-pager-view": "6.1.2",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
"react-native-reanimated": "~2.14.4",
|
"react-native-reanimated": "~2.14.4",
|
||||||
"react-native-root-siblings": "^4.1.1",
|
"react-native-root-siblings": "^4.1.1",
|
||||||
"react-native-safe-area-context": "^4.4.1",
|
"react-native-safe-area-context": "^4.4.1",
|
||||||
"react-native-screens": "^3.13.1",
|
"react-native-screens": "^3.13.1",
|
||||||
"react-native-splash-screen": "^3.3.0",
|
"react-native-splash-screen": "^3.3.0",
|
||||||
"react-native-svg": "^13.4.0",
|
"react-native-svg": "13.4.0",
|
||||||
"react-native-tab-view": "^3.3.0",
|
|
||||||
"react-native-url-polyfill": "^1.3.0",
|
"react-native-url-polyfill": "^1.3.0",
|
||||||
"react-native-uuid": "^2.0.1",
|
"react-native-uuid": "^2.0.1",
|
||||||
"react-native-version-number": "^0.3.6",
|
"react-native-version-number": "^0.3.6",
|
||||||
"react-native-web": "^0.18.11",
|
"react-native-web": "^0.18.11",
|
||||||
"react-native-web-linear-gradient": "^1.1.2",
|
"react-native-web-linear-gradient": "^1.1.2",
|
||||||
"react-native-web-webview": "^1.0.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",
|
"react-native-youtube-iframe": "^2.2.2",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"tippy.js": "^6.3.7",
|
"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 {RootSiblingParent} from 'react-native-root-siblings'
|
||||||
import SplashScreen from 'react-native-splash-screen'
|
import SplashScreen from 'react-native-splash-screen'
|
||||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
|
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {ThemeProvider} from 'lib/ThemeContext'
|
import {ThemeProvider} from 'lib/ThemeContext'
|
||||||
|
import {s} from 'lib/styles'
|
||||||
import * as view from './view/index'
|
import * as view from './view/index'
|
||||||
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
import {RootStoreModel, setupState, RootStoreProvider} from './state'
|
||||||
import {Shell} from './view/shell'
|
import {Shell} from './view/shell'
|
||||||
|
@ -51,9 +53,11 @@ const App = observer(() => {
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<analytics.Provider>
|
<analytics.Provider>
|
||||||
<RootStoreProvider value={rootStore}>
|
<RootStoreProvider value={rootStore}>
|
||||||
<SafeAreaProvider>
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<Shell />
|
<SafeAreaProvider>
|
||||||
</SafeAreaProvider>
|
<Shell />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
</RootStoreProvider>
|
</RootStoreProvider>
|
||||||
</analytics.Provider>
|
</analytics.Provider>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import {AppBskyFeedFeedViewPost} from '@atproto/api'
|
import {AppBskyFeedFeedViewPost} from '@atproto/api'
|
||||||
|
import lande from 'lande'
|
||||||
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
||||||
|
import {hasProp} from '@atproto/lexicon'
|
||||||
|
|
||||||
export type FeedTunerFn = (
|
export type FeedTunerFn = (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
|
@ -140,7 +142,8 @@ export class FeedTuner {
|
||||||
for (const item of slice.items) {
|
for (const item of slice.items) {
|
||||||
this.seenUris.add(item.post.uri)
|
this.seenUris.add(item.post.uri)
|
||||||
}
|
}
|
||||||
slice.logSelf()
|
// DEBUG uncomment to get a quick view of the data
|
||||||
|
// slice.logSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
return slices
|
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 {
|
function getSelfReplyUri(item: FeedViewPost): string | undefined {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext'
|
||||||
export interface UsePaletteValue {
|
export interface UsePaletteValue {
|
||||||
colors: PaletteColor
|
colors: PaletteColor
|
||||||
view: ViewStyle
|
view: ViewStyle
|
||||||
|
viewLight: ViewStyle
|
||||||
btn: ViewStyle
|
btn: ViewStyle
|
||||||
border: ViewStyle
|
border: ViewStyle
|
||||||
borderDark: ViewStyle
|
borderDark: ViewStyle
|
||||||
|
@ -20,6 +21,9 @@ export function usePalette(color: PaletteColorName): UsePaletteValue {
|
||||||
view: {
|
view: {
|
||||||
backgroundColor: palette.background,
|
backgroundColor: palette.background,
|
||||||
},
|
},
|
||||||
|
viewLight: {
|
||||||
|
backgroundColor: palette.backgroundLight,
|
||||||
|
},
|
||||||
btn: {
|
btn: {
|
||||||
backgroundColor: palette.backgroundLight,
|
backgroundColor: palette.backgroundLight,
|
||||||
},
|
},
|
||||||
|
|
|
@ -70,6 +70,7 @@ export const s = StyleSheet.create({
|
||||||
borderRight1: {borderRightWidth: 1},
|
borderRight1: {borderRightWidth: 1},
|
||||||
borderBottom1: {borderBottomWidth: 1},
|
borderBottom1: {borderBottomWidth: 1},
|
||||||
borderLeft1: {borderLeftWidth: 1},
|
borderLeft1: {borderLeftWidth: 1},
|
||||||
|
hidden: {display: 'none'},
|
||||||
|
|
||||||
// font weights
|
// font weights
|
||||||
fw600: {fontWeight: '600'},
|
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(
|
constructor(
|
||||||
public rootStore: RootStoreModel,
|
public rootStore: RootStoreModel,
|
||||||
public feedType: 'home' | 'author' | 'suggested',
|
public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
|
||||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
||||||
) {
|
) {
|
||||||
makeAutoObservable(
|
makeAutoObservable(
|
||||||
|
@ -336,6 +336,20 @@ export class FeedModel {
|
||||||
return this.setup()
|
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
|
* Load for first render
|
||||||
*/
|
*/
|
||||||
|
@ -399,6 +413,7 @@ export class FeedModel {
|
||||||
params: this.params,
|
params: this.params,
|
||||||
e,
|
e,
|
||||||
})
|
})
|
||||||
|
this.hasMore = false
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.lock.release()
|
this.lock.release()
|
||||||
|
@ -476,7 +491,8 @@ export class FeedModel {
|
||||||
}
|
}
|
||||||
const res = await this._getFeed({limit: 1})
|
const res = await this._getFeed({limit: 1})
|
||||||
const currentLatestUri = this.pollCursor
|
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) {
|
if (!item) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -541,12 +557,7 @@ export class FeedModel {
|
||||||
this.loadMoreCursor = res.data.cursor
|
this.loadMoreCursor = res.data.cursor
|
||||||
this.hasMore = !!this.loadMoreCursor
|
this.hasMore = !!this.loadMoreCursor
|
||||||
|
|
||||||
const slices = this.tuner.tune(
|
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
|
||||||
res.data.feed,
|
|
||||||
this.feedType === 'home'
|
|
||||||
? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toAppend: FeedSliceModel[] = []
|
const toAppend: FeedSliceModel[] = []
|
||||||
for (const slice of slices) {
|
for (const slice of slices) {
|
||||||
|
@ -571,12 +582,7 @@ export class FeedModel {
|
||||||
) {
|
) {
|
||||||
this.pollCursor = res.data.feed[0]?.post.uri
|
this.pollCursor = res.data.feed[0]?.post.uri
|
||||||
|
|
||||||
const slices = this.tuner.tune(
|
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
|
||||||
res.data.feed,
|
|
||||||
this.feedType === 'home'
|
|
||||||
? [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
|
|
||||||
: [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const toPrepend: FeedSliceModel[] = []
|
const toPrepend: FeedSliceModel[] = []
|
||||||
for (const slice of slices) {
|
for (const slice of slices) {
|
||||||
|
@ -634,6 +640,15 @@ export class FeedModel {
|
||||||
return this.rootStore.api.app.bsky.feed.getTimeline(
|
return this.rootStore.api.app.bsky.feed.getTimeline(
|
||||||
params as GetTimeline.QueryParams,
|
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 {
|
} else {
|
||||||
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
|
return this.rootStore.api.app.bsky.feed.getAuthorFeed(
|
||||||
params as GetAuthorFeed.QueryParams,
|
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() {
|
clear() {
|
||||||
this.mainFeed.clear()
|
this.mainFeed.clear()
|
||||||
this.notifications.clear()
|
this.notifications.clear()
|
||||||
|
this.follows.clear()
|
||||||
this.did = ''
|
this.did = ''
|
||||||
this.handle = ''
|
this.handle = ''
|
||||||
this.displayName = ''
|
this.displayName = ''
|
||||||
|
|
|
@ -35,6 +35,12 @@ export class MyFollowsModel {
|
||||||
// public api
|
// public api
|
||||||
// =
|
// =
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.followDidToRecordMap = {}
|
||||||
|
this.lastSync = 0
|
||||||
|
this.myDid = undefined
|
||||||
|
}
|
||||||
|
|
||||||
fetchIfNeeded = bundleAsync(async () => {
|
fetchIfNeeded = bundleAsync(async () => {
|
||||||
if (
|
if (
|
||||||
this.myDid !== this.rootStore.me.did ||
|
this.myDid !== this.rootStore.me.did ||
|
||||||
|
|
|
@ -154,13 +154,13 @@ export class SessionModel {
|
||||||
/**
|
/**
|
||||||
* Sets the active session
|
* Sets the active session
|
||||||
*/
|
*/
|
||||||
setActiveSession(agent: AtpAgent, did: string) {
|
async setActiveSession(agent: AtpAgent, did: string) {
|
||||||
this._log('SessionModel:setActiveSession')
|
this._log('SessionModel:setActiveSession')
|
||||||
this.data = {
|
this.data = {
|
||||||
service: agent.service.toString(),
|
service: agent.service.toString(),
|
||||||
did,
|
did,
|
||||||
}
|
}
|
||||||
this.rootStore.handleSessionChange(agent)
|
await this.rootStore.handleSessionChange(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -304,7 +304,7 @@ export class SessionModel {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setActiveSession(agent, account.did)
|
await this.setActiveSession(agent, account.did)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -337,7 +337,7 @@ export class SessionModel {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
this.setActiveSession(agent, did)
|
await this.setActiveSession(agent, did)
|
||||||
this._log('SessionModel:login succeeded')
|
this._log('SessionModel:login succeeded')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -376,8 +376,7 @@ export class SessionModel {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
this.setActiveSession(agent, did)
|
await this.setActiveSession(agent, did)
|
||||||
this.rootStore.shell.setOnboarding(true)
|
|
||||||
this._log('SessionModel:createAccount succeeded')
|
this._log('SessionModel:createAccount succeeded')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,13 +122,13 @@ export class ShellUiModel {
|
||||||
darkMode = false
|
darkMode = false
|
||||||
minimalShellMode = false
|
minimalShellMode = false
|
||||||
isDrawerOpen = false
|
isDrawerOpen = false
|
||||||
|
isDrawerSwipeDisabled = false
|
||||||
isModalActive = false
|
isModalActive = false
|
||||||
activeModals: Modal[] = []
|
activeModals: Modal[] = []
|
||||||
isLightboxActive = false
|
isLightboxActive = false
|
||||||
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
|
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
|
||||||
isComposerActive = false
|
isComposerActive = false
|
||||||
composerOpts: ComposerOpts | undefined
|
composerOpts: ComposerOpts | undefined
|
||||||
isOnboarding = false
|
|
||||||
|
|
||||||
constructor(public rootStore: RootStoreModel) {
|
constructor(public rootStore: RootStoreModel) {
|
||||||
makeAutoObservable(this, {
|
makeAutoObservable(this, {
|
||||||
|
@ -168,6 +168,10 @@ export class ShellUiModel {
|
||||||
this.isDrawerOpen = false
|
this.isDrawerOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsDrawerSwipeDisabled(v: boolean) {
|
||||||
|
this.isDrawerSwipeDisabled = v
|
||||||
|
}
|
||||||
|
|
||||||
openModal(modal: Modal) {
|
openModal(modal: Modal) {
|
||||||
this.rootStore.emitNavigation()
|
this.rootStore.emitNavigation()
|
||||||
this.isModalActive = true
|
this.isModalActive = true
|
||||||
|
@ -200,13 +204,4 @@ export class ShellUiModel {
|
||||||
this.isComposerActive = false
|
this.isComposerActive = false
|
||||||
this.composerOpts = undefined
|
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,116 +1,68 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {CenteredView, FlatList} from '../util/Views'
|
import {AppBskyActorRef, AppBskyActorProfile} from '@atproto/api'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
|
||||||
import {ErrorScreen} from '../util/error/ErrorScreen'
|
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
import {useStores} from 'state/index'
|
import {Text} from '../util/text/Text'
|
||||||
import {
|
|
||||||
SuggestedActorsViewModel,
|
|
||||||
SuggestedActor,
|
|
||||||
} from 'state/models/suggested-actors-view'
|
|
||||||
import {s} from 'lib/styles'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
|
||||||
export const SuggestedFollows = observer(
|
export const SuggestedFollows = ({
|
||||||
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
|
title,
|
||||||
const pal = usePalette('default')
|
suggestions,
|
||||||
const store = useStores()
|
}: {
|
||||||
|
title: string
|
||||||
const view = React.useMemo<SuggestedActorsViewModel>(
|
suggestions: (AppBskyActorRef.WithInfo | RefWithInfoAndFollowers)[]
|
||||||
() => new SuggestedActorsViewModel(store),
|
}) => {
|
||||||
[store],
|
const pal = usePalette('default')
|
||||||
)
|
return (
|
||||||
|
<View style={[styles.container, pal.view]}>
|
||||||
React.useEffect(() => {
|
<Text type="title" style={[styles.heading, pal.text]}>
|
||||||
view
|
{title}
|
||||||
.loadMore()
|
</Text>
|
||||||
.catch((err: any) =>
|
{suggestions.map(item => (
|
||||||
store.log.error('Failed to fetch suggestions', err),
|
<View key={item.did} style={[styles.card, pal.view, pal.border]}>
|
||||||
)
|
<ProfileCardWithFollowBtn
|
||||||
}, [view, store.log])
|
key={item.did}
|
||||||
|
did={item.did}
|
||||||
React.useEffect(() => {
|
declarationCid={item.declaration.cid}
|
||||||
if (!view.isLoading && !view.hasError && !view.hasContent) {
|
handle={item.handle}
|
||||||
onNoSuggestions?.()
|
displayName={item.displayName}
|
||||||
}
|
avatar={item.avatar}
|
||||||
}, [view, view.isLoading, view.hasError, view.hasContent, onNoSuggestions])
|
noBg
|
||||||
|
noBorder
|
||||||
const onRefresh = () => {
|
description=""
|
||||||
view
|
followers={
|
||||||
.refresh()
|
item.followers
|
||||||
.catch((err: any) =>
|
? (item.followers as AppBskyActorProfile.View[])
|
||||||
store.log.error('Failed to fetch suggestions', err),
|
: undefined
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
const onEndReached = () => {
|
</View>
|
||||||
view
|
))}
|
||||||
.loadMore()
|
</View>
|
||||||
.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>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
height: '100%',
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
|
|
||||||
suggestionsContainer: {
|
heading: {
|
||||||
height: '100%',
|
fontWeight: 'bold',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingBottom: 8,
|
||||||
},
|
},
|
||||||
footer: {
|
|
||||||
height: 200,
|
card: {
|
||||||
paddingTop: 20,
|
borderRadius: 12,
|
||||||
|
marginBottom: 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore: {
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -7,23 +7,17 @@ import {
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} 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 {CenteredView, FlatList} from '../util/Views'
|
||||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
import {Text} from '../util/text/Text'
|
import {ViewHeader} from '../util/ViewHeader'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||||
import {Button} from '../util/forms/Button'
|
|
||||||
import {FeedModel} from 'state/models/feed-view'
|
import {FeedModel} from 'state/models/feed-view'
|
||||||
import {FeedSlice} from './FeedSlice'
|
import {FeedSlice} from './FeedSlice'
|
||||||
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
import {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 EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
|
||||||
|
|
||||||
|
@ -34,6 +28,7 @@ export const Feed = observer(function Feed({
|
||||||
scrollElRef,
|
scrollElRef,
|
||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScroll,
|
onScroll,
|
||||||
|
renderEmptyState,
|
||||||
testID,
|
testID,
|
||||||
headerOffset = 0,
|
headerOffset = 0,
|
||||||
}: {
|
}: {
|
||||||
|
@ -43,17 +38,15 @@ export const Feed = observer(function Feed({
|
||||||
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
scrollElRef?: MutableRefObject<FlatList<any> | null>
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScroll?: OnScrollCb
|
onScroll?: OnScrollCb
|
||||||
|
renderEmptyState?: () => JSX.Element
|
||||||
testID?: string
|
testID?: string
|
||||||
headerOffset?: number
|
headerOffset?: number
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
|
||||||
const palInverted = usePalette('inverted')
|
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||||
const navigation = useNavigation<NavigationProp>()
|
|
||||||
|
|
||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
let feedItems: any[] = []
|
let feedItems: any[] = [HEADER_ITEM]
|
||||||
if (feed.hasLoaded) {
|
if (feed.hasLoaded) {
|
||||||
if (feed.hasError) {
|
if (feed.hasError) {
|
||||||
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
feedItems = feedItems.concat([ERROR_FEED_ITEM])
|
||||||
|
@ -80,6 +73,7 @@ export const Feed = observer(function Feed({
|
||||||
}
|
}
|
||||||
setIsRefreshing(false)
|
setIsRefreshing(false)
|
||||||
}, [feed, track, setIsRefreshing])
|
}, [feed, track, setIsRefreshing])
|
||||||
|
|
||||||
const onEndReached = React.useCallback(async () => {
|
const onEndReached = React.useCallback(async () => {
|
||||||
track('Feed:onEndReached')
|
track('Feed:onEndReached')
|
||||||
try {
|
try {
|
||||||
|
@ -95,37 +89,10 @@ export const Feed = observer(function Feed({
|
||||||
const renderItem = React.useCallback(
|
const renderItem = React.useCallback(
|
||||||
({item}: {item: any}) => {
|
({item}: {item: any}) => {
|
||||||
if (item === EMPTY_FEED_ITEM) {
|
if (item === EMPTY_FEED_ITEM) {
|
||||||
return (
|
if (renderEmptyState) {
|
||||||
<View style={styles.emptyContainer}>
|
return renderEmptyState()
|
||||||
<View style={styles.emptyIconContainer}>
|
}
|
||||||
<MagnifyingGlassIcon
|
return <View />
|
||||||
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>
|
|
||||||
)
|
|
||||||
} else if (item === ERROR_FEED_ITEM) {
|
} else if (item === ERROR_FEED_ITEM) {
|
||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
|
@ -133,10 +100,12 @@ export const Feed = observer(function Feed({
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (item === HEADER_ITEM) {
|
||||||
|
return <ViewHeader title="Bluesky" canGoBack={false} />
|
||||||
}
|
}
|
||||||
return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
|
return <FeedSlice slice={item} showFollowBtn={showPostFollowBtn} />
|
||||||
},
|
},
|
||||||
[feed, onPressTryAgain, showPostFollowBtn, pal, palInverted, navigation],
|
[feed, onPressTryAgain, showPostFollowBtn, renderEmptyState],
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeedFooter = React.useCallback(
|
const FeedFooter = React.useCallback(
|
||||||
|
@ -183,21 +152,4 @@ export const Feed = observer(function Feed({
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
feedFooter: {paddingTop: 20},
|
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 React from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
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 {useStores} from 'state/index'
|
||||||
import * as apilib from 'lib/api/index'
|
import * as apilib from 'lib/api/index'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
|
|
||||||
const FollowButton = observer(
|
const FollowButton = observer(
|
||||||
({
|
({
|
||||||
|
type = 'inverted',
|
||||||
did,
|
did,
|
||||||
declarationCid,
|
declarationCid,
|
||||||
onToggleFollow,
|
onToggleFollow,
|
||||||
}: {
|
}: {
|
||||||
|
type?: ButtonType
|
||||||
did: string
|
did: string
|
||||||
declarationCid: string
|
declarationCid: string
|
||||||
onToggleFollow?: (v: boolean) => void
|
onToggleFollow?: (v: boolean) => void
|
||||||
|
@ -42,7 +44,7 @@ const FollowButton = observer(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type={isFollowing ? 'default' : 'primary'}
|
type={isFollowing ? 'default' : type}
|
||||||
onPress={onToggleFollowInner}
|
onPress={onToggleFollowInner}
|
||||||
label={isFollowing ? 'Unfollow' : 'Follow'}
|
label={isFollowing ? 'Unfollow' : 'Follow'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {AppBskyActorProfile} from '@atproto/api'
|
||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
|
@ -15,7 +16,9 @@ export function ProfileCard({
|
||||||
avatar,
|
avatar,
|
||||||
description,
|
description,
|
||||||
isFollowedBy,
|
isFollowedBy,
|
||||||
|
noBg,
|
||||||
noBorder,
|
noBorder,
|
||||||
|
followers,
|
||||||
renderButton,
|
renderButton,
|
||||||
}: {
|
}: {
|
||||||
handle: string
|
handle: string
|
||||||
|
@ -23,7 +26,9 @@ export function ProfileCard({
|
||||||
avatar?: string
|
avatar?: string
|
||||||
description?: string
|
description?: string
|
||||||
isFollowedBy?: boolean
|
isFollowedBy?: boolean
|
||||||
|
noBg?: boolean
|
||||||
noBorder?: boolean
|
noBorder?: boolean
|
||||||
|
followers?: AppBskyActorProfile.View[] | undefined
|
||||||
renderButton?: () => JSX.Element
|
renderButton?: () => JSX.Element
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -31,9 +36,9 @@ export function ProfileCard({
|
||||||
<Link
|
<Link
|
||||||
style={[
|
style={[
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.view,
|
|
||||||
pal.border,
|
pal.border,
|
||||||
noBorder && styles.outerNoBorder,
|
noBorder && styles.outerNoBorder,
|
||||||
|
!noBg && pal.view,
|
||||||
]}
|
]}
|
||||||
href={`/profile/${handle}`}
|
href={`/profile/${handle}`}
|
||||||
title={handle}
|
title={handle}
|
||||||
|
@ -73,6 +78,25 @@ export function ProfileCard({
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : 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>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -86,6 +110,9 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
avatar,
|
avatar,
|
||||||
description,
|
description,
|
||||||
isFollowedBy,
|
isFollowedBy,
|
||||||
|
noBg,
|
||||||
|
noBorder,
|
||||||
|
followers,
|
||||||
}: {
|
}: {
|
||||||
did: string
|
did: string
|
||||||
declarationCid: string
|
declarationCid: string
|
||||||
|
@ -94,6 +121,9 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
avatar?: string
|
avatar?: string
|
||||||
description?: string
|
description?: string
|
||||||
isFollowedBy?: boolean
|
isFollowedBy?: boolean
|
||||||
|
noBg?: boolean
|
||||||
|
noBorder?: boolean
|
||||||
|
followers?: AppBskyActorProfile.View[] | undefined
|
||||||
}) => {
|
}) => {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const isMe = store.me.handle === handle
|
const isMe = store.me.handle === handle
|
||||||
|
@ -105,6 +135,9 @@ export const ProfileCardWithFollowBtn = observer(
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
description={description}
|
description={description}
|
||||||
isFollowedBy={isFollowedBy}
|
isFollowedBy={isFollowedBy}
|
||||||
|
noBg={noBg}
|
||||||
|
noBorder={noBorder}
|
||||||
|
followers={followers}
|
||||||
renderButton={
|
renderButton={
|
||||||
isMe
|
isMe
|
||||||
? undefined
|
? undefined
|
||||||
|
@ -128,8 +161,8 @@ const styles = StyleSheet.create({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
layoutAvi: {
|
layoutAvi: {
|
||||||
width: 60,
|
width: 54,
|
||||||
paddingLeft: 10,
|
paddingLeft: 4,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
},
|
},
|
||||||
|
@ -164,4 +197,27 @@ const styles = StyleSheet.create({
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
paddingHorizontal: 14,
|
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({
|
const styles = StyleSheet.create({
|
||||||
loadingPlaceholder: {
|
loadingPlaceholder: {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
|
@ -147,6 +187,15 @@ const styles = StyleSheet.create({
|
||||||
paddingLeft: 46,
|
paddingLeft: 46,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
},
|
},
|
||||||
|
profileCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
padding: 10,
|
||||||
|
margin: 1,
|
||||||
|
},
|
||||||
|
profileCardAvi: {
|
||||||
|
borderRadius: 20,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
smallAvatar: {
|
smallAvatar: {
|
||||||
borderRadius: 15,
|
borderRadius: 15,
|
||||||
marginRight: 10,
|
marginRight: 10,
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
// two-liner with follow button
|
// two-liner with follow button
|
||||||
return (
|
return (
|
||||||
<View style={styles.metaTwoLine}>
|
<View style={styles.metaTwoLine}>
|
||||||
<View>
|
<View style={styles.metaTwoLineLeft}>
|
||||||
<View style={styles.metaTwoLineTop}>
|
<View style={styles.metaTwoLineTop}>
|
||||||
<DesktopWebTextLink
|
<DesktopWebTextLink
|
||||||
type="lg-bold"
|
type="lg-bold"
|
||||||
|
@ -69,6 +69,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
type="md"
|
type="md"
|
||||||
style={[styles.metaItem, pal.textLight]}
|
style={[styles.metaItem, pal.textLight]}
|
||||||
lineHeight={1.2}
|
lineHeight={1.2}
|
||||||
|
numberOfLines={1}
|
||||||
text={`@${handle}`}
|
text={`@${handle}`}
|
||||||
href={`/profile/${opts.authorHandle}`}
|
href={`/profile/${opts.authorHandle}`}
|
||||||
/>
|
/>
|
||||||
|
@ -76,6 +77,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<FollowButton
|
<FollowButton
|
||||||
|
type="default"
|
||||||
did={opts.did}
|
did={opts.did}
|
||||||
declarationCid={opts.declarationCid}
|
declarationCid={opts.declarationCid}
|
||||||
onToggleFollow={onToggleFollow}
|
onToggleFollow={onToggleFollow}
|
||||||
|
@ -134,7 +136,12 @@ const styles = StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingBottom: 2,
|
width: '100%',
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
metaTwoLineLeft: {
|
||||||
|
flex: 1,
|
||||||
|
paddingRight: 40,
|
||||||
},
|
},
|
||||||
metaTwoLineTop: {
|
metaTwoLineTop: {
|
||||||
flexDirection: 'row',
|
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 React from 'react'
|
||||||
import {FlatList, View} from 'react-native'
|
import {FlatList, View, useWindowDimensions} from 'react-native'
|
||||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import useAppState from 'react-native-appstate-hook'
|
import useAppState from 'react-native-appstate-hook'
|
||||||
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
|
import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types'
|
||||||
|
import {FeedModel} from 'state/models/feed-view'
|
||||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {Feed} from '../com/posts/Feed'
|
import {Feed} from '../com/posts/Feed'
|
||||||
|
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||||
import {LoadLatestBtn} from '../com/util/LoadLatestBtn'
|
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 {FAB} from '../com/util/FAB'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
import {ComposeIcon2} from 'lib/icons'
|
import {ComposeIcon2} from 'lib/icons'
|
||||||
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
|
||||||
const HEADER_HEIGHT = 42
|
const TAB_BAR_HEIGHT = 82
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||||
export const HomeScreen = withAuthRequired(
|
export const HomeScreen = withAuthRequired((_opts: Props) => {
|
||||||
observer(function Home(_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 store = useStores()
|
||||||
const onMainScroll = useOnMainScroll(store)
|
const onMainScroll = useOnMainScroll(store)
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
|
@ -28,38 +99,51 @@ export const HomeScreen = withAuthRequired(
|
||||||
const {appState} = useAppState({
|
const {appState} = useAppState({
|
||||||
onForeground: () => doPoll(true),
|
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(
|
const doPoll = React.useCallback(
|
||||||
(knownActive = false) => {
|
(knownActive = false) => {
|
||||||
if ((!knownActive && appState !== 'active') || !isFocused) {
|
if (
|
||||||
|
(!knownActive && appState !== 'active') ||
|
||||||
|
!isScreenFocused ||
|
||||||
|
!isPageFocused
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.me.mainFeed.isLoading) {
|
if (feed.isLoading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.log.debug('HomeScreen: Polling for new posts')
|
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(() => {
|
const scrollToTop = React.useCallback(() => {
|
||||||
// NOTE: the feed is offset by the height of the collapsing header,
|
scrollElRef.current?.scrollToOffset({offset: 0})
|
||||||
// so we scroll to the negative of that height -prf
|
|
||||||
scrollElRef.current?.scrollToOffset({offset: -HEADER_HEIGHT})
|
|
||||||
}, [scrollElRef])
|
}, [scrollElRef])
|
||||||
|
|
||||||
|
const onSoftReset = React.useCallback(() => {
|
||||||
|
if (isPageFocused) {
|
||||||
|
scrollToTop()
|
||||||
|
}
|
||||||
|
}, [isPageFocused, scrollToTop])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
const softResetSub = store.onScreenSoftReset(scrollToTop)
|
const softResetSub = store.onScreenSoftReset(onSoftReset)
|
||||||
const feedCleanup = store.me.mainFeed.registerListeners()
|
const feedCleanup = feed.registerListeners()
|
||||||
const pollInterval = setInterval(doPoll, 15e3)
|
const pollInterval = setInterval(doPoll, 15e3)
|
||||||
|
|
||||||
screen('Feed')
|
screen('Feed')
|
||||||
store.log.debug('HomeScreen: Updating feed')
|
store.log.debug('HomeScreen: Updating feed')
|
||||||
if (store.me.mainFeed.hasContent) {
|
if (feed.hasContent) {
|
||||||
store.me.mainFeed.update()
|
feed.update()
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -67,7 +151,7 @@ export const HomeScreen = withAuthRequired(
|
||||||
softResetSub.remove()
|
softResetSub.remove()
|
||||||
feedCleanup()
|
feedCleanup()
|
||||||
}
|
}
|
||||||
}, [store, doPoll, scrollToTop, screen]),
|
}, [store, doPoll, onSoftReset, screen, feed]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCompose = React.useCallback(() => {
|
const onPressCompose = React.useCallback(() => {
|
||||||
|
@ -76,32 +160,28 @@ export const HomeScreen = withAuthRequired(
|
||||||
}, [store, track])
|
}, [store, track])
|
||||||
|
|
||||||
const onPressTryAgain = React.useCallback(() => {
|
const onPressTryAgain = React.useCallback(() => {
|
||||||
store.me.mainFeed.refresh()
|
feed.refresh()
|
||||||
}, [store])
|
}, [feed])
|
||||||
|
|
||||||
const onPressLoadLatest = React.useCallback(() => {
|
const onPressLoadLatest = React.useCallback(() => {
|
||||||
store.me.mainFeed.refresh()
|
feed.refresh()
|
||||||
scrollToTop()
|
scrollToTop()
|
||||||
}, [store, scrollToTop])
|
}, [feed, scrollToTop])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={s.hContentRegion}>
|
<View style={containerStyle}>
|
||||||
{store.shell.isOnboarding && <WelcomeBanner />}
|
|
||||||
<Feed
|
<Feed
|
||||||
testID="homeFeed"
|
testID="homeFeed"
|
||||||
key="default"
|
key="default"
|
||||||
feed={store.me.mainFeed}
|
feed={feed}
|
||||||
scrollElRef={scrollElRef}
|
scrollElRef={scrollElRef}
|
||||||
style={s.hContentRegion}
|
style={s.hContentRegion}
|
||||||
showPostFollowBtn
|
showPostFollowBtn
|
||||||
onPressTryAgain={onPressTryAgain}
|
onPressTryAgain={onPressTryAgain}
|
||||||
onScroll={onMainScroll}
|
onScroll={onMainScroll}
|
||||||
headerOffset={store.shell.isOnboarding ? 0 : HEADER_HEIGHT}
|
renderEmptyState={renderEmptyState}
|
||||||
/>
|
/>
|
||||||
{!store.shell.isOnboarding && (
|
{feed.hasNewLatest && !feed.isRefreshing && (
|
||||||
<ViewHeader title="Bluesky" canGoBack={false} hideOnScroll />
|
|
||||||
)}
|
|
||||||
{store.me.mainFeed.hasNewLatest && !store.me.mainFeed.isRefreshing && (
|
|
||||||
<LoadLatestBtn onPress={onPressLoadLatest} />
|
<LoadLatestBtn onPress={onPressLoadLatest} />
|
||||||
)}
|
)}
|
||||||
<FAB
|
<FAB
|
||||||
|
@ -111,5 +191,5 @@ export const HomeScreen = withAuthRequired(
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}),
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
RefreshControl,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TextInput,
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
@ -13,21 +14,23 @@ import {
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from 'view/com/util/Views'
|
||||||
import {
|
import {
|
||||||
NativeStackScreenProps,
|
NativeStackScreenProps,
|
||||||
SearchTabNavigatorParams,
|
SearchTabNavigatorParams,
|
||||||
} from 'lib/routes/types'
|
} from 'lib/routes/types'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {UserAvatar} from '../com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
|
||||||
|
import {FoafsModel} from 'state/models/discovery/foafs'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {MagnifyingGlassIcon} from 'lib/icons'
|
import {MagnifyingGlassIcon} from 'lib/icons'
|
||||||
import {WhoToFollow} from '../com/discover/WhoToFollow'
|
import {WhoToFollow} from 'view/com/discover/WhoToFollow'
|
||||||
import {SuggestedPosts} from '../com/discover/SuggestedPosts'
|
import {SuggestedFollows} from 'view/com/discover/SuggestedFollows'
|
||||||
import {ProfileCard} from '../com/profile/ProfileCard'
|
import {ProfileCard} from 'view/com/profile/ProfileCard'
|
||||||
|
import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
|
||||||
|
@ -53,6 +56,11 @@ export const SearchScreen = withAuthRequired(
|
||||||
() => new UserAutocompleteViewModel(store),
|
() => new UserAutocompleteViewModel(store),
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
|
const foafsView = React.useMemo<FoafsModel>(
|
||||||
|
() => new FoafsModel(store),
|
||||||
|
[store],
|
||||||
|
)
|
||||||
|
const [refreshing, setRefreshing] = React.useState(false)
|
||||||
|
|
||||||
const onSoftReset = () => {
|
const onSoftReset = () => {
|
||||||
scrollElRef.current?.scrollTo({x: 0, y: 0})
|
scrollElRef.current?.scrollTo({x: 0, y: 0})
|
||||||
|
@ -71,9 +79,12 @@ export const SearchScreen = withAuthRequired(
|
||||||
}
|
}
|
||||||
store.shell.setMinimalShellMode(false)
|
store.shell.setMinimalShellMode(false)
|
||||||
autocompleteView.setup()
|
autocompleteView.setup()
|
||||||
|
if (!foafsView.hasData) {
|
||||||
|
foafsView.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
return cleanup
|
return cleanup
|
||||||
}, [store, autocompleteView, lastRenderTime, setRenderTime]),
|
}, [store, autocompleteView, foafsView, lastRenderTime, setRenderTime]),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressMenu = () => {
|
const onPressMenu = () => {
|
||||||
|
@ -98,15 +109,18 @@ export const SearchScreen = withAuthRequired(
|
||||||
autocompleteView.setActive(false)
|
autocompleteView.setActive(false)
|
||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
}
|
}
|
||||||
|
const onRefresh = React.useCallback(async () => {
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await foafsView.fetch()
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
|
}, [foafsView, setRefreshing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
<ScrollView
|
<View style={[pal.view, styles.container]}>
|
||||||
ref={scrollElRef}
|
|
||||||
testID="searchScrollView"
|
|
||||||
style={[pal.view, styles.container]}
|
|
||||||
onScroll={onMainScroll}
|
|
||||||
scrollEventThrottle={100}>
|
|
||||||
<View style={[pal.view, pal.border, styles.header]}>
|
<View style={[pal.view, pal.border, styles.header]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="viewHeaderBackOrMenuBtn"
|
testID="viewHeaderBackOrMenuBtn"
|
||||||
|
@ -180,14 +194,53 @@ export const SearchScreen = withAuthRequired(
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView onScroll={Keyboard.dismiss}>
|
<ScrollView
|
||||||
<WhoToFollow key={`wtf-${lastRenderTime}`} />
|
ref={scrollElRef}
|
||||||
<SuggestedPosts key={`sp-${lastRenderTime}`} />
|
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} />
|
<View style={s.footerSpacer} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
<View style={s.footerSpacer} />
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
@ -235,4 +288,8 @@ const styles = StyleSheet.create({
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
paddingTop: 10,
|
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 minimalShellInterp = useAnimatedValue(0)
|
||||||
const safeAreaInsets = useSafeAreaInsets()
|
const safeAreaInsets = useSafeAreaInsets()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {isAtHome, isAtSearch, isAtNotifications} = useNavigationState(
|
const {isAtHome, isAtSearch, isAtNotifications, noBorder} =
|
||||||
state => {
|
useNavigationState(state => {
|
||||||
return {
|
const res = {
|
||||||
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
isAtHome: getTabState(state, 'Home') !== TabState.Outside,
|
||||||
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
|
||||||
isAtNotifications:
|
isAtNotifications:
|
||||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (store.shell.minimalShellMode) {
|
if (store.shell.minimalShellMode) {
|
||||||
|
@ -99,6 +107,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.bottomBar,
|
styles.bottomBar,
|
||||||
|
noBorder && styles.noBorder,
|
||||||
pal.view,
|
pal.view,
|
||||||
pal.border,
|
pal.border,
|
||||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
|
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
|
||||||
|
@ -213,6 +222,9 @@ const styles = StyleSheet.create({
|
||||||
paddingLeft: 5,
|
paddingLeft: 5,
|
||||||
paddingRight: 10,
|
paddingRight: 10,
|
||||||
},
|
},
|
||||||
|
noBorder: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
ctrl: {
|
ctrl: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingTop: 13,
|
paddingTop: 13,
|
||||||
|
|
|
@ -46,7 +46,11 @@ const ShellInner = observer(() => {
|
||||||
onOpen={onOpenDrawer}
|
onOpen={onOpenDrawer}
|
||||||
onClose={onCloseDrawer}
|
onClose={onCloseDrawer}
|
||||||
swipeEdgeWidth={winDim.width}
|
swipeEdgeWidth={winDim.width}
|
||||||
swipeEnabled={!canGoBack && store.session.hasSession}>
|
swipeEnabled={
|
||||||
|
!canGoBack &&
|
||||||
|
store.session.hasSession &&
|
||||||
|
!store.shell.isDrawerSwipeDisabled
|
||||||
|
}>
|
||||||
<TabsNavigator />
|
<TabsNavigator />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
Loading…
Reference in New Issue