Compare commits

...

24 Commits

Author SHA1 Message Date
Eric Bailey 238b00d193 Configure moderation 2025-04-10 17:05:11 -05:00
Paul Frazee bf69672674 Update translations from crowdin (#8172) 2025-04-09 16:25:16 -07:00
Eric Bailey 5371114609 Add translate metrics (#8171)
* Add translate metrics

* Format
2025-04-09 15:00:40 -07:00
surfdude29 4c0213b3e9 update crowdin links to point to public page (#8100) 2025-04-09 12:28:06 -07:00
Eric Bailey 02a05d9948 Pass in Sentry vars to prod android build (#8170) 2025-04-09 11:29:37 -07:00
pfrazee 4c142a86b4 Nightly source-language update 2025-04-09 02:37:13 +00:00
Paul Frazee f3153b8acb Release 1.100 (#8168)
* Tests fix

* Move the hosting provider text in account creation into the inside of the button to improve translation handling (close #8166)

* Pull latest translations

* Revert the 'you are creating an account' change entirely; it doesnt work
2025-04-08 18:16:04 -07:00
Paul Frazee a59fb91a99 Increase suggested follows in the new user progress guide modal (#8162)
* Increase number of accounts suggested in the follow dialog progress guide

* Increase the bottom pad on the dialog flatlist component

* Format

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-04-08 18:16:13 -05:00
Eric Bailey c8568e3004 [Explore] Reduced experience (#8160)
* Only show suggested users for non-english users

* Fall back to searching for users for non-english speakers

* Disable other queries if full experience is disabled

* Bump package

* If no content langs, use full exp
2025-04-08 17:08:09 -05:00
Eric Bailey 09111ef272 Tweak performance settings on Android (#8158) 2025-04-08 11:20:03 -07:00
pfrazee b9ed94c7f4 Nightly source-language update 2025-04-08 02:36:28 +00:00
Eric Bailey 872fcd1d20 Update patches (#8151)
* Update patches after #8133

* Bump deps

* Revert @react-native-picker/picker downgrade

* Migrate RN patch
2025-04-07 18:29:41 -07:00
Eric Bailey d496b5bdbf Add in new suggested accounts endpoint into FollowDialog (#8147)
* Add in new suggested accounts endpoint into FollowDialog

* Fix lint

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 18:29:04 -07:00
Eric Bailey 4879a07701 Rm sticky headers (#8154) 2025-04-07 17:41:20 -07:00
Eric Bailey 28876a4b23 Slice 'For You' results (#8152) 2025-04-07 17:40:31 -07:00
surfdude29 1e8a96e297 Align copy in Your interests toasts (#8138)
* update copy in toasts

* prettier
2025-04-07 18:42:29 -05:00
Eric Bailey 815a1e4c44 Center the selected tab in the sugg follows tabbar (#8146)
Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 16:34:05 -07:00
Eric Bailey 4013855c10 [Explore] Small fixes (#8145)
* Hover on sugg account

* Add subtle hover to all components except feed

* Use skeleton states for refetch on focus

* Empty results state for sugg users

* Filter out pinned posts from feed previews

* Add trending header if not top module

* Tighten up spacing

* Fetch 10 profiles

* Update interests copy

* Remove refetch on focus

* Add PTR

* use a map

* Update src/screens/Search/modules/ExploreInterestsCard.tsx

* fix web double border

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-07 16:32:30 -07:00
Eric Bailey e46b78eb85 Remove unused index to fix lint (#8148) 2025-04-07 16:06:28 -07:00
pfrazee 94fd12ff49 Nightly source-language update 2025-04-05 02:35:03 +00:00
Paul Frazee a181b0b935 Rework the visual design of the interests card in explore (#8135) 2025-04-04 18:10:13 -07:00
Paul Frazee a301e31f76 Fix the 'seen' metric in explore (#8134) 2025-04-04 17:28:02 -07:00
Eric Bailey 502d660eb4 [Explore] Invalidate queries post-interests edit (#8128)
* Insert fresh interests and reset relevant queries

* reset suggested users

---------

Co-authored-by: Hailey <me@haileyok.com>
2025-04-04 17:16:39 -07:00
Paul Frazee 6840fbb237 Update deps to support ios 18.4 (#8133) 2025-04-04 17:04:57 -07:00
77 changed files with 35540 additions and 26383 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ jobs:
- name: 🏗️ Build Production APK
if: ${{ inputs.profile == 'production' }}
run: yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
run: SENTRY_DIST=${{ steps.sentry.outputs.SENTRY_DIST }} SENTRY_RELEASE=${{ steps.sentry.outputs.SENTRY_RELEASE }} SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} yarn use-build-number-with-bump eas build -p android --profile production-apk --local --output build.apk --non-interactive
- name: 🚀 Upload Production APK Artifact
id: upload-artifact-production-apk
-1
View File
@@ -17,7 +17,6 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+4 -4
View File
@@ -1,11 +1,11 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/u/projects/1) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please get involved on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or open an issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app).
## Tools
- We use Crowdin to manage translations.
- Bluesky Crowdin: https://bluesky.crowdin.com/u/projects/1
- Bluesky Crowdin: https://bluesky.crowdin.com/bluesky-social
- Introduction to Crowdin: https://support.crowdin.com/for-translators/
- We use Lingui to implement translations. You can find the documentation [here](https://lingui.dev/).
@@ -15,7 +15,7 @@ Much of the app is translated by community contributions. (We <3 our translators
### Using Crowdin
[Crowdin](https://bluesky.crowdin.com/u/projects/1) is our primary tool for managing translations. There are two roles:
[Crowdin](https://bluesky.crowdin.com/bluesky-social) is our primary tool for managing translations. There are two roles:
- **Proof-readers**. Can create new translations and approve submitted translations.
- **Translators**. Can create new translations.
@@ -32,7 +32,7 @@ Please treat everyone with respect. Proof-readers are given final say on transla
### Adding a new language
You can request a new language be added to the project by clicking **Request New Language** on Crowdin or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
You can request a new language be added to the project by clicking **Request New Language** on [Crowdin](https://bluesky.crowdin.com/bluesky-social) or you can create a [GitHub issue](https://github.com/bluesky-social/social-app/issues).
Please only request a new language when you are certain you will be able to contribute a substantive portion of translations for the language.
+44 -44
View File
@@ -58,7 +58,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.14.20",
"@atproto/api": "^0.14.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -86,12 +86,12 @@
"@radix-ui/react-focus-scope": "^1.1.2",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-menu/menu": "^1.1.7",
"@react-native-picker/picker": "2.10.2",
"@react-native-picker/picker": "2.10.3",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"@sentry/react-native": "6.9.1",
"@sentry/react-native": "~6.10.0",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@@ -123,35 +123,35 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.19",
"expo-application": "^6.0.1",
"expo": "~52.0.42",
"expo-application": "~6.0.2",
"expo-blur": "^14.0.3",
"expo-build-properties": "^0.13.1",
"expo-camera": "~16.0.7",
"expo-clipboard": "^7.0.0",
"expo-dev-client": "^5.0.4",
"expo-device": "~7.0.1",
"expo-file-system": "^18.0.6",
"expo-font": "~13.0.1",
"expo-haptics": "^14.0.0",
"expo-image": "~2.0.3",
"expo-image-manipulator": "^13.0.5",
"expo-image-picker": "~16.0.3",
"expo-linear-gradient": "^14.0.1",
"expo-linking": "^7.0.3",
"expo-localization": "~16.0.0",
"expo-media-library": "~17.0.3",
"expo-navigation-bar": "~4.0.4",
"expo-notifications": "~0.29.11",
"expo-screen-orientation": "^8.0.2",
"expo-sharing": "^13.0.0",
"expo-splash-screen": "~0.29.18",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "^4.0.4",
"expo-task-manager": "~12.0.3",
"expo-updates": "~0.26.10",
"expo-video": "^2.0.5",
"expo-web-browser": "~14.0.1",
"expo-build-properties": "~0.13.2",
"expo-camera": "~16.0.18",
"expo-clipboard": "~7.0.1",
"expo-dev-client": "~5.0.19",
"expo-device": "~7.0.3",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-manipulator": "~13.0.6",
"expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.5",
"expo-localization": "~16.0.1",
"expo-media-library": "~17.0.6",
"expo-navigation-bar": "~4.0.9",
"expo-notifications": "~0.29.14",
"expo-screen-orientation": "~8.0.4",
"expo-sharing": "~13.0.1",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-task-manager": "~12.0.6",
"expo-updates": "~0.27.4",
"expo-video": "~2.0.6",
"expo-web-browser": "~14.0.2",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.5.11",
@@ -169,12 +169,12 @@
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
"react": "18.2.0",
"react": "18.3.1",
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
"react-dom": "^18.2.0",
"react-dom": "18.3.1",
"react-image-crop": "^11.0.7",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.76.6",
"react-native": "0.76.9",
"react-native-compressor": "1.10.3",
"react-native-date-picker": "^5.0.7",
"react-native-drawer-layout": "^4.1.1",
@@ -185,15 +185,15 @@
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.5",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.7.0",
"react-native-pager-view": "6.5.1",
"react-native-picker-select": "^9.3.1",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
"react-native-reanimated": "3.17.0-nightly-20241211-17e89ca24",
"react-native-reanimated": "~3.16.1",
"react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.14.0",
"react-native-screens": "~4.3.0",
"react-native-svg": "^15.8.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-uitextview": "^1.4.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.2",
@@ -201,7 +201,7 @@
"react-native-vision-camera": "^4.6.3",
"react-native-web": "~0.19.13",
"react-native-web-webview": "^1.0.2",
"react-native-webview": "13.12.3",
"react-native-webview": "13.12.5",
"react-remove-scroll-bar": "^2.3.6",
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
@@ -223,8 +223,8 @@
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/eslint-config": "^0.76.2",
"@react-native/typescript-config": "^0.76.1",
"@react-native/eslint-config": "^0.76.9",
"@react-native/typescript-config": "^0.76.9",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^12.8.1",
@@ -257,7 +257,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "^52.0.2",
"jest-expo": "~52.0.6",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -273,7 +273,7 @@
},
"resolutions": {
"@expo/image-utils": "0.6.3",
"@react-native/babel-preset": "0.76.1",
"@react-native/babel-preset": "0.76.9",
"@react-native/normalize-colors": "0.76.1",
"@types/react": "^18",
"**/expo-constants": "17.0.3",
@@ -1,8 +1,8 @@
diff --git a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
index b928b8f..7175cf6 100644
index 64e6efe..e61485e 100644
--- a/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
+++ b/node_modules/expo-media-library/android/src/main/java/expo/modules/medialibrary/MediaLibraryModule.kt
@@ -99,12 +99,12 @@ class MediaLibraryModule : Module() {
@@ -111,12 +111,12 @@ class MediaLibraryModule : Module() {
}
AsyncFunction("createAssetAsync") { localUri: String, promise: Promise ->
@@ -1,5 +1,5 @@
diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
index 5dd0c61..bf536dd 100644
index 13bffbb..5ebbede 100644
--- a/node_modules/expo-notifications/android/build.gradle
+++ b/node_modules/expo-notifications/android/build.gradle
@@ -46,6 +46,7 @@ dependencies {
@@ -41,7 +41,7 @@ diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/Scro
index 93af874..106f8ec 100644
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
@@ -66,28 +66,51 @@ - (void)preserveContentOffsetWithBlock:(void (^)())block
@@ -66,28 +66,51 @@
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
* becomes larger than the ScrollView, there is no padding around the content but it
* can still fill the whole view.
@@ -103,7 +103,7 @@ index 93af874..106f8ec 100644
- (BOOL)touchesShouldCancelInContentView:(UIView *)view
{
if ([_overridingDelegate respondsToSelector:@selector(touchesShouldCancelInContentView:)]) {
@@ -257,6 +280,10 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
@@ -257,6 +280,10 @@
}
}
@@ -131,7 +131,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 53bfd04..ff1b1ed 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -23,6 +23,7 @@ @implementation RCTRefreshControl {
@@ -23,6 +23,7 @@
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
@@ -139,7 +139,7 @@ index 53bfd04..ff1b1ed 100644
}
- (instancetype)init
@@ -58,6 +59,12 @@ - (void)layoutSubviews
@@ -58,6 +59,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
_isInitialRender = false;
}
@@ -152,7 +152,7 @@ index 53bfd04..ff1b1ed 100644
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -221,4 +228,50 @@ - (void)refreshControlValueChanged
@@ -221,4 +228,50 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
}
}
@@ -207,7 +207,7 @@ diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshCont
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
@@ -22,11 +22,12 @@ RCT_EXPORT_MODULE()
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
@@ -222,10 +222,10 @@ index 40aaf9c..1c60164 100644
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
index e9ce48c..84a6fca 100644
index 6f41b5c..9b4f77f 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollView.m
@@ -159,26 +159,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view
@@ -159,26 +159,8 @@
return !shouldDisableScrollInteraction;
}
@@ -252,7 +252,7 @@ index e9ce48c..84a6fca 100644
super.contentOffset = CGPointMake(
RCTSanitizeNaNValue(contentOffset.x, @"scrollView.contentOffset.x"),
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
@@ -427,6 +409,11 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
@@ -433,6 +415,11 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
// Does nothing
}
@@ -264,7 +264,7 @@ index e9ce48c..84a6fca 100644
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -443,6 +430,8 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
@@ -449,6 +436,8 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
_contentView = view;
RCTApplyTransformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
@@ -273,7 +273,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -652,9 +641,46 @@ -(void)delegateMethod : (UIScrollView *)scrollView \
@@ -658,9 +647,46 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
}
RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, onMomentumScrollBegin)
@@ -321,7 +321,7 @@ index e9ce48c..84a6fca 100644
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener
{
[_scrollListeners addObject:scrollListener];
@@ -933,6 +959,7 @@ - (void)updateContentSizeIfNeeded
@@ -939,6 +965,7 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
CGSize contentSize = self.contentSize;
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
_scrollView.contentSize = contentSize;
@@ -329,7 +329,7 @@ index e9ce48c..84a6fca 100644
}
}
@@ -1055,6 +1082,22 @@ -(type)getter \
@@ -1061,6 +1088,22 @@ RCT_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalSc
RCT_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
RCT_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
@@ -356,7 +356,7 @@ diff --git a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManag
index cd1e7eb..c1d0172 100644
--- a/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
+++ b/node_modules/react-native/React/Views/ScrollView/RCTScrollViewManager.m
@@ -83,6 +83,7 @@ - (UIView *)view
@@ -83,6 +83,7 @@ RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator, BOOL)
RCT_EXPORT_VIEW_PROPERTY(scrollEventThrottle, NSTimeInterval)
RCT_EXPORT_VIEW_PROPERTY(zoomScale, CGFloat)
RCT_EXPORT_VIEW_PROPERTY(contentInset, UIEdgeInsets)
+14 -14
View File
@@ -1,20 +1,20 @@
import React, {useImperativeHandle} from 'react'
import {
NativeScrollEvent,
NativeSyntheticEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
ScrollView,
StyleProp,
type ScrollView,
type StyleProp,
TextInput,
View,
ViewStyle,
type ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -25,22 +25,22 @@ import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {List, type ListMethods, type ListProps} from '#/view/com/util/List'
import {atoms as a, tokens, useTheme} from '#/alf'
import {useThemeName} from '#/alf/util/useColorModeTheme'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
type DialogControlProps,
type DialogInnerProps,
type DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
type BottomSheetSnapPointChangeEvent,
type BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
import {type BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
@@ -299,7 +299,7 @@ export const InnerFlatList = React.forwardRef<
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
<View style={{height: insets.bottom + a.pt_5xl.paddingTop + 50}} />
}
ref={ref}
{...props}
+100 -174
View File
@@ -7,24 +7,17 @@ import {
View,
type ViewStyle,
} from 'react-native'
import Animated, {
LayoutAnimationConfig,
LinearTransition,
ZoomInEasyDown,
} from 'react-native-reanimated'
import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSession} from '#/state/session'
import {type Follow10ProgressGuide} from '#/state/shell/progress-guide'
import {type ListMethods} from '#/view/com/util/List'
@@ -49,15 +42,14 @@ import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/componen
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ListFooter} from '../Lists'
import type * as bsky from '#/types/bsky'
import {ProgressGuideTask} from './Task'
type Item =
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileView
isSuggestion: boolean
profile: bsky.profile.AnyProfileView
}
| {
type: 'empty'
@@ -128,94 +120,39 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
const inputRef = useRef<TextInput>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const {currentAccount} = useSession()
const [suggestedAccounts, setSuggestedAccounts] = useState<
Map<string, AppBskyActorDefs.ProfileView[]>
>(() => new Map())
useEffect(() => {
lastSearchText = searchText
lastSelectedInterest = selectedInterest
}, [searchText, selectedInterest])
const query = searchText || selectedInterest
const {
data: suggestions,
isFetching: isFetchingSuggestions,
error: suggestionsError,
} = useGetSuggestedUsersQuery({
category: selectedInterest,
limit: 50,
})
const {
data: searchResults,
isFetching,
error,
isError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isFetching: isFetchingSearchResults,
error: searchResultsError,
isError: isSearchResultsError,
} = useActorSearchPaginated({
query,
enabled: !!searchText,
query: searchText,
})
const hasSearchText = !!searchText
const resultsKey = searchText || selectedInterest
const items = useMemo(() => {
const results = searchResults?.pages.flatMap(r => r.actors)
const results = hasSearchText
? searchResults?.pages.flatMap(p => p.actors)
: suggestions?.actors
let _items: Item[] = []
const seen = new Set<string>()
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (results) {
// First pass: search results
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
// my sincere apologies to Jake Gold - your bio is too keyword-filled and
// your page-rank too high, so you're at the top of half the categories -sfn
if (
!hasSearchText &&
profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' &&
// constrain to 'tech'
selectedInterest !== 'tech'
) {
continue
}
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: query + ':' + profile.did,
profile,
isSuggestion: false,
})
}
// Second pass: suggestions
_items = _items.flatMap(item => {
if (item.type !== 'profile') {
return item
}
const suggestions = suggestedAccounts.get(item.profile.did)
if (!suggestions) {
return item
}
const itemWithSuggestions = [item]
for (const suggested of suggestions) {
if (seen.has(suggested.did)) {
// Skip search results from previous step or already seen suggestions
continue
}
seen.add(suggested.did)
itemWithSuggestions.push({
type: 'profile',
key: suggested.did,
profile: suggested,
isSuggestion: true,
})
if (itemWithSuggestions.length === 1 + 3) {
break
}
}
return itemWithSuggestions
})
} else {
if (isFetchingSuggestions || isFetchingSearchResults) {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
@@ -224,21 +161,54 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
}))
_items.push(...placeholders)
} else if (
(hasSearchText && searchResultsError) ||
(!hasSearchText && suggestionsError) ||
!results?.length
) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else {
const seen = new Set<string>()
for (const profile of results) {
if (seen.has(profile.did)) continue
if (profile.did === currentAccount?.did) continue
if (profile.viewer?.following) continue
seen.add(profile.did)
_items.push({
type: 'profile',
// Don't share identity across tabs or typing attempts
key: resultsKey + ':' + profile.did,
profile,
})
}
}
return _items
}, [
_,
suggestions,
suggestionsError,
isFetchingSuggestions,
searchResults,
isError,
searchResultsError,
isFetchingSearchResults,
currentAccount?.did,
hasSearchText,
selectedInterest,
suggestedAccounts,
query,
resultsKey,
])
if (searchText && !isFetching && !items.length && !isError) {
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
@@ -249,9 +219,7 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
return (
<FollowProfileCard
profile={item.profile}
isSuggestion={item.isSuggestion}
moderationOpts={moderationOpts!}
setSuggestedAccounts={setSuggestedAccounts}
noBorder={index === 0}
/>
)
@@ -297,15 +265,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
/>
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more people to follow', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Dialog.InnerFlatList
ref={listRef}
@@ -325,15 +284,6 @@ function DialogInner({guide}: {guide: Follow10ProgressGuide}) {
scrollIndicatorInsets={{top: headerHeight}}
initialNumToRender={8}
maxToRenderPerBatch={8}
onEndReached={onEndReached}
itemLayoutAnimation={LinearTransition}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
/>
}
/>
)
}
@@ -471,7 +421,6 @@ let Tabs = ({
contentContainerStyle?: StyleProp<ViewStyle>
}): React.ReactNode => {
const listRef = useRef<ScrollView>(null)
const [scrollX, setScrollX] = useState(0)
const [totalWidth, setTotalWidth] = useState(0)
const pendingTabOffsets = useRef<{x: number; width: number}[]>([])
const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([])
@@ -490,24 +439,11 @@ let Tabs = ({
function scrollIntoViewIfNeeded(index: number) {
const btnLayout = tabOffsets[index]
if (!btnLayout) return
const viewportLeftEdge = scrollX
const viewportRightEdge = scrollX + totalWidth
const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x
const shouldScrollToRightEdge =
viewportRightEdge < btnLayout.x + btnLayout.width
if (shouldScrollToLeftEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - tokens.space.lg,
animated: true,
})
} else if (shouldScrollToRightEdge) {
listRef.current?.scrollTo({
x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg,
animated: true,
})
}
listRef.current?.scrollTo({
// centered
x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2),
animated: true,
})
}
function handleSelectTab(index: number) {
@@ -539,7 +475,7 @@ let Tabs = ({
}
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
scrollEventThrottle={200} // big throttle
onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}>
>
{interests.map((interest, i) => {
const active = interest === selectedInterest && !hasSearchText
return (
@@ -575,6 +511,7 @@ let Tab = ({
interestsDisplayName: string
onLayout: (index: number, x: number, width: number) => void
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const activeText = active ? _(msg` (active)`) : ''
return (
@@ -585,12 +522,32 @@ let Tab = ({
}>
<Button
label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
variant={active ? 'solid' : 'outline'}
color={active ? 'primary' : 'secondary'}
size="small"
onPress={() => onSelectTab(index)}>
<ButtonIcon icon={SearchIcon} />
<ButtonText>{interestsDisplayName}</ButtonText>
{({hovered, pressed, focused}) => (
<View
style={[
a.rounded_full,
a.px_lg,
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
style={[
/* TODO: medium weight */
active || hovered || pressed || focused
? t.atoms.text
: t.atoms.text_contrast_medium,
]}>
{interestsDisplayName}
</Text>
</View>
)}
</Button>
</View>
)
@@ -600,49 +557,18 @@ Tab = memo(Tab)
let FollowProfileCard = ({
profile,
moderationOpts,
isSuggestion,
setSuggestedAccounts,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
isSuggestion: boolean
setSuggestedAccounts: (
updater: (
v: Map<string, AppBskyActorDefs.ProfileView[]>,
) => Map<string, AppBskyActorDefs.ProfileView[]>,
) => void
noBorder?: boolean
}): React.ReactNode => {
const [hasFollowed, setHasFollowed] = useState(false)
const followupSuggestion = useSuggestedFollowsByActorQuery({
did: profile.did,
enabled: hasFollowed,
})
const candidates = followupSuggestion.data?.suggestions
useEffect(() => {
// TODO: Move out of effect.
if (hasFollowed && candidates && candidates.length > 0) {
setSuggestedAccounts(suggestions => {
const newSuggestions = new Map(suggestions)
newSuggestions.set(profile.did, candidates)
return newSuggestions
})
}
}, [hasFollowed, profile.did, candidates, setSuggestedAccounts])
return (
<LayoutAnimationConfig skipEntering={!isSuggestion}>
<Animated.View entering={native(ZoomInEasyDown)}>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
onFollow={() => setHasFollowed(true)}
noBorder={noBorder}
/>
</Animated.View>
</LayoutAnimationConfig>
<FollowProfileCardInner
profile={profile}
moderationOpts={moderationOpts}
noBorder={noBorder}
/>
)
}
FollowProfileCard = memo(FollowProfileCard)
@@ -653,7 +579,7 @@ function FollowProfileCardInner({
onFollow,
noBorder,
}: {
profile: AppBskyActorDefs.ProfileView
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onFollow?: () => void
noBorder?: boolean
@@ -670,7 +596,7 @@ function FollowProfileCardInner({
style={[
a.flex_1,
noBorder && a.border_t_0,
(hovered || pressed) && t.atoms.border_contrast_high,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
+34
View File
@@ -0,0 +1,34 @@
import {View} from 'react-native'
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
export function SubtleHover({style, hover}: ViewStyleProp & {hover: boolean}) {
const t = useTheme()
let opacity: number
switch (t.name) {
case 'dark':
opacity = 0.4
break
case 'dim':
opacity = 0.45
break
case 'light':
opacity = 0.5
break
}
return (
<View
style={[
a.absolute,
a.inset_0,
a.pointer_events_none,
a.transition_opacity,
t.atoms.bg_contrast_25,
style,
{opacity: hover ? opacity : 0},
]}
/>
)
}
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
@@ -61,6 +62,12 @@ export let MessageContextMenu = ({
langPrefs.primaryLanguage,
)
openLink(translatorUrl, true)
logger.metric('translate', {
sourceLanguages: [],
targetLanguage: langPrefs.primaryLanguage,
textLength: message.text.length,
})
}, [langPrefs.primaryLanguage, message.text, openLink])
const onDelete = useCallback(() => {
+19 -17
View File
@@ -41,25 +41,27 @@ export function HostingProvider({
onSelect={onSelectServiceUrl}
/>
{minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_xs]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>
You are creating an account on{' '}
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {margin: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</Trans>
<Trans>You are creating an account on</Trans>
</Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[
a.px_xs,
{marginHorizontal: tokens.space.xs * -1},
{paddingVertical: 0},
]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -330,7 +330,6 @@ export type MetricEvents = {
| 'suggestedFeeds'
| 'suggestedStarterPacks'
| `feed:${FeedDescriptor}`
index: number
}
'explore:module:searchButtonPress': {
module: 'suggestedAccounts' | 'suggestedFeeds'
@@ -365,4 +364,10 @@ export type MetricEvents = {
details: boolean
}
'reportDialog:failure': {}
translate: {
sourceLanguages: string[]
targetLanguage: string
textLength: number
}
}
+222 -111
View File
@@ -1,5 +1,5 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {View, type ViewabilityConfig, type ViewToken} from 'react-native'
import {View, type ViewabilityConfig} from 'react-native'
import {
type AppBskyActorDefs,
type AppBskyFeedDefs,
@@ -7,14 +7,17 @@ import {
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useGate} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {type MetricEvents} from '#/logger/metrics'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {RQKEY_ROOT_PAGINATED as useActorSearchPaginatedQueryKeyRoot} from '#/state/queries/actor-search'
import {
type FeedPreviewItem,
useFeedPreviews,
@@ -22,9 +25,16 @@ import {
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {Nux, useNux} from '#/state/queries/nuxs'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useGetSuggestedFeedsQuery} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useSuggestedStarterPacksQuery} from '#/state/queries/useSuggestedStarterPacksQuery'
import {
createGetSuggestedFeedsQueryKey,
useGetSuggestedFeedsQuery,
} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {getSuggestedUsersQueryKeyRoot} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {createGetTrendsQueryKey} from '#/state/queries/trending/useGetTrendsQuery'
import {
createSuggestedStarterPacksQueryKey,
useSuggestedStarterPacksQuery,
} from '#/state/queries/useSuggestedStarterPacksQuery'
import {useProgressGuide} from '#/state/shell/progress-guide'
import {isThreadChildAt, isThreadParentAt} from '#/view/com/posts/PostFeed'
import {PostFeedItem} from '#/view/com/posts/PostFeedItem'
@@ -32,6 +42,10 @@ import {ViewFullThread} from '#/view/com/posts/ViewFullThread'
import {List} from '#/view/com/util/List'
import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {
popularInterests,
useInterestsDisplayNames,
} from '#/screens/Onboarding/state'
import {
StarterPackCard,
StarterPackCardSkeleton,
@@ -40,7 +54,9 @@ import {ExploreInterestsCard} from '#/screens/Search/modules/ExploreInterestsCar
import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations'
import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics'
import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos'
import {atoms as a, native, platform, useTheme, web} from '#/alf'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
@@ -49,9 +65,12 @@ import {type Props as IcoProps} from '#/components/icons/common'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
import {StarterPack} from '#/components/icons/StarterPack'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending'
import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import * as ModuleHeader from './components/ModuleHeader'
import {
@@ -69,33 +88,26 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
onPress={item.onLoadMore}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_center,
a.px_lg,
a.py_md,
a.gap_sm,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<Text
<>
<SubtleHover hover={hovered || pressed} />
<View
style={[
a.leading_snug,
hovered ? t.atoms.text : t.atoms.text_contrast_medium,
a.flex_1,
a.flex_row,
a.align_center,
a.justify_center,
a.px_lg,
a.py_md,
a.gap_sm,
]}>
{item.message}
</Text>
{item.isLoadingMore ? (
<Loader size="sm" />
) : (
<ChevronDownIcon
size="sm"
style={hovered ? t.atoms.text : t.atoms.text_contrast_medium}
/>
)}
</View>
<Text style={[a.leading_snug]}>{item.message}</Text>
{item.isLoadingMore ? (
<Loader size="sm" />
) : (
<ChevronDownIcon size="sm" style={t.atoms.text_contrast_medium} />
)}
</View>
</>
)}
</Button>
)
@@ -112,6 +124,7 @@ type ExploreScreenItems =
title: string
icon: React.ComponentType<SVGIconProps>
iconSize?: IcoProps['size']
bottomBorder?: boolean
searchButton?: {
label: string
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
@@ -128,6 +141,7 @@ type ExploreScreenItems =
metricsTag: MetricEvents['explore:module:searchButtonPress']['module']
tab: 'user' | 'profile' | 'feed'
}
hideDefaultTab?: boolean
}
| {
type: 'trendingTopics'
@@ -144,9 +158,13 @@ type ExploreScreenItems =
| {
type: 'profile'
key: string
profile: AppBskyActorDefs.ProfileViewBasic
profile: AppBskyActorDefs.ProfileView
recId?: number
}
| {
type: 'profileEmpty'
key: 'profileEmpty'
}
| {
type: 'feed'
key: string
@@ -190,27 +208,42 @@ type ExploreScreenItems =
export function Explore({
focusSearchInput,
headerHeight,
}: {
focusSearchInput: (tab: 'user' | 'profile' | 'feed') => void
headerHeight: number
}) {
const {_} = useLingui()
const t = useTheme()
const initialNumToRender = useInitialNumToRender()
const {data: preferences, error: preferencesError} = usePreferencesQuery()
const moderationOpts = useModerationOpts()
const gate = useGate()
const guide = useProgressGuide('follow-10')
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
// TODO always get at least 10 back
/*
* Begin special language handling
*/
const {contentLanguages} = useLanguagePrefs()
const useFullExperience = useMemo(() => {
if (contentLanguages.length === 0) return true
return bcp47Match.basicFilter('en', contentLanguages).length > 0
}, [contentLanguages])
const personalizedInterests = preferences?.interests?.tags
const interestsDisplayNames = useInterestsDisplayNames()
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(personalizedInterests))
const {
data: suggestedUsers,
isLoading: suggestedUsersIsLoading,
error: suggestedUsersError,
} = useGetSuggestedUsersQuery({
category: selectedInterest,
isRefetching: suggestedUsersIsRefetching,
} = useSuggestedUsers({
category: selectedInterest || (useFullExperience ? null : interests[0]),
search: !useFullExperience,
})
/* End special language handling */
const {
data: feeds,
hasNextPage: hasNextFeedsPage,
@@ -218,7 +251,7 @@ export function Explore({
isFetchingNextPage: isFetchingNextFeedsPage,
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
} = useGetPopularFeedsQuery({limit: 10, enabled: useFullExperience})
const interestsNux = useNux(Nux.ExploreInterestsCard)
const showInterestsNux =
interestsNux.status === 'ready' && !interestsNux.nux?.completed
@@ -227,7 +260,8 @@ export function Explore({
data: suggestedSPs,
isLoading: isLoadingSuggestedSPs,
error: suggestedSPsError,
} = useSuggestedStarterPacksQuery()
isRefetching: isRefetchingSuggestedSPs,
} = useSuggestedStarterPacksQuery({enabled: useFullExperience})
const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds
const [hasPressedLoadMoreFeeds, setHasPressedLoadMoreFeeds] = useState(false)
@@ -250,7 +284,9 @@ export function Explore({
hasPressedLoadMoreFeeds,
])
const {data: suggestedFeeds} = useGetSuggestedFeedsQuery()
const {data: suggestedFeeds} = useGetSuggestedFeedsQuery({
enabled: useFullExperience,
})
const {
data: feedPreviewSlices,
query: {
@@ -260,7 +296,31 @@ export function Explore({
hasNextPage: hasNextPageFeedPreviews,
error: feedPreviewSlicesError,
},
} = useFeedPreviews(suggestedFeeds?.feeds ?? [])
} = useFeedPreviews(suggestedFeeds?.feeds ?? [], useFullExperience)
const qc = useQueryClient()
const [isPTR, setIsPTR] = useState(false)
const onPTR = useCallback(async () => {
setIsPTR(true)
await Promise.all([
await qc.resetQueries({
queryKey: createGetTrendsQueryKey(),
}),
await qc.resetQueries({
queryKey: createSuggestedStarterPacksQueryKey(),
}),
await qc.resetQueries({
queryKey: [getSuggestedUsersQueryKeyRoot],
}),
await qc.resetQueries({
queryKey: [useActorSearchPaginatedQueryKeyRoot],
}),
await qc.resetQueries({
queryKey: createGetSuggestedFeedsQueryKey(),
}),
])
setIsPTR(false)
}, [qc, setIsPTR])
const onLoadMoreFeedPreviews = useCallback(async () => {
if (
@@ -303,9 +363,10 @@ export function Explore({
metricsTag: 'suggestedAccounts',
tab: 'user',
},
hideDefaultTab: !useFullExperience,
})
if (suggestedUsersIsLoading) {
if (suggestedUsersIsLoading || suggestedUsersIsRefetching) {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
} else if (suggestedUsersError) {
i.push({
@@ -322,6 +383,7 @@ export function Explore({
let seen = new Set()
const profileItems: ExploreScreenItems[] = []
for (const actor of suggestedUsers.actors) {
// checking for following still necessary if search data is used
if (!seen.has(actor.did) && !actor.viewer?.following) {
seen.add(actor.did)
profileItems.push({
@@ -333,14 +395,23 @@ export function Explore({
}
if (profileItems.length === 0) {
// no items! remove the header
i.pop()
i.push({
type: 'profileEmpty',
key: 'profileEmpty',
})
} else {
i.push(...profileItems)
if (selectedInterest === null && useFullExperience) {
// First "For You" tab, only show 5 to keep screen short
i.push(...profileItems.slice(0, 5))
} else {
i.push(...profileItems)
}
}
} else {
// no items! remove the header
i.pop()
i.push({
type: 'profileEmpty',
key: 'profileEmpty',
})
}
} else {
i.push({type: 'profilePlaceholder', key: 'profilePlaceholder'})
@@ -352,7 +423,10 @@ export function Explore({
moderationOpts,
suggestedUsers,
suggestedUsersIsLoading,
suggestedUsersIsRefetching,
suggestedUsersError,
selectedInterest,
useFullExperience,
])
const suggestedFeedsModule = useMemo(() => {
const i: ExploreScreenItems[] = []
@@ -466,7 +540,7 @@ export function Explore({
iconSize: 'xl',
})
if (isLoadingSuggestedSPs) {
if (isLoadingSuggestedSPs || isRefetchingSuggestedSPs) {
Array.from({length: 3}).forEach((__, index) =>
i.push({
type: 'starterPackSkeleton',
@@ -486,7 +560,13 @@ export function Explore({
})
}
return i
}, [suggestedSPs, _, isLoadingSuggestedSPs, suggestedSPsError])
}, [
suggestedSPs,
_,
isLoadingSuggestedSPs,
suggestedSPsError,
isRefetchingSuggestedSPs,
])
const feedPreviewsModule = useMemo(() => {
const i: ExploreScreenItems[] = []
i.push(...feedPreviewSlices)
@@ -517,22 +597,35 @@ export function Explore({
i.push(topBorder)
i.push(...interestsNuxModule)
if (isNewUser) {
i.push(...suggestedFollowsModule)
i.push(...suggestedStarterPacksModule)
i.push(trendingTopicsModule)
if (useFullExperience) {
if (isNewUser) {
i.push(...suggestedFollowsModule)
i.push(...suggestedStarterPacksModule)
i.push({
type: 'header',
key: 'trending-topics-header',
title: _(msg`Trending topics`),
icon: Graph,
bottomBorder: true,
})
i.push(trendingTopicsModule)
} else {
i.push(trendingTopicsModule)
i.push(...suggestedFollowsModule)
i.push(...suggestedStarterPacksModule)
}
if (gate('explore_show_suggested_feeds')) {
i.push(...suggestedFeedsModule)
}
i.push(...feedPreviewsModule)
} else {
i.push(trendingTopicsModule)
i.push(...suggestedFollowsModule)
i.push(...suggestedStarterPacksModule)
}
if (gate('explore_show_suggested_feeds')) {
i.push(...suggestedFeedsModule)
}
i.push(...feedPreviewsModule)
return i
}, [
_,
topBorder,
isNewUser,
suggestedFollowsModule,
@@ -542,6 +635,7 @@ export function Explore({
feedPreviewsModule,
interestsNuxModule,
gate,
useFullExperience,
])
const renderItem = useCallback(
@@ -549,22 +643,11 @@ export function Explore({
switch (item.type) {
case 'topBorder':
return (
<View
style={[
a.w_full,
t.atoms.border_contrast_low,
a.border_t,
headerHeight &&
web({
position: 'sticky',
top: headerHeight,
}),
]}
/>
<View style={[a.w_full, t.atoms.border_contrast_low, a.border_t]} />
)
case 'header': {
return (
<ModuleHeader.Container>
<ModuleHeader.Container bottomBorder={item.bottomBorder}>
<ModuleHeader.Icon icon={item.icon} size={item.iconSize} />
<ModuleHeader.TitleText>{item.title}</ModuleHeader.TitleText>
{item.searchButton && (
@@ -596,6 +679,7 @@ export function Explore({
<SuggestedAccountsTabBar
selectedInterest={selectedInterest}
onSelectInterest={setSelectedInterest}
hideDefaultTab={item.hideDefaultTab}
/>
</View>
)
@@ -623,6 +707,21 @@ export function Explore({
/>
)
}
case 'profileEmpty': {
return (
<View style={[a.px_lg, a.pb_lg]}>
<Admonition>
{selectedInterest ? (
<Trans>
No results for "{interestsDisplayNames[selectedInterest]}".
</Trans>
) : (
<Trans>No results.</Trans>
)}
</Admonition>
</View>
)
}
case 'feed': {
return (
<View
@@ -737,8 +836,13 @@ export function Explore({
case 'preview:header': {
return (
<ModuleHeader.Container
headerHeight={headerHeight}
style={[a.pt_xs, a.border_b, t.atoms.border_contrast_low]}>
style={[
a.pt_xs,
t.atoms.border_contrast_low,
native(a.border_b),
]}>
{/* Very non-scientific way to avoid small gap on scroll */}
<View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
<ModuleHeader.FeedLink feed={item.feed}>
<ModuleHeader.FeedAvatar feed={item.feed} />
<View style={[a.flex_1, a.gap_xs]}>
@@ -817,9 +921,9 @@ export function Explore({
focusSearchInput,
moderationOpts,
selectedInterest,
interestsDisplayNames,
_,
fetchNextPageFeedPreviews,
headerHeight,
],
)
@@ -837,36 +941,26 @@ export function Explore({
// track headers and report module viewability
const alreadyReportedRef = useRef<Map<string, string>>(new Map())
const onViewableItemsChanged = useCallback(
({
viewableItems,
}: {
viewableItems: ViewToken<ExploreScreenItems>[]
changed: ViewToken<ExploreScreenItems>[]
}) => {
for (const {item, index} of viewableItems.filter(vi => vi.isViewable)) {
let module: MetricEvents['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type
} else if (item.type === 'profile') {
module = 'suggestedAccounts'
} else if (item.type === 'feed') {
module = 'suggestedFeeds'
} else if (item.type === 'starterPack') {
module = 'suggestedStarterPacks'
} else if (item.type === 'preview:header') {
module = `feed:feedgen|${item.feed.uri}`
} else {
continue
}
if (!alreadyReportedRef.current.has(module)) {
alreadyReportedRef.current.set(module, module)
logger.metric('explore:module:seen', {module, index: index ?? -1})
}
}
},
[],
)
const onItemSeen = useCallback((item: ExploreScreenItems) => {
let module: MetricEvents['explore:module:seen']['module']
if (item.type === 'trendingTopics' || item.type === 'trendingVideos') {
module = item.type
} else if (item.type === 'profile') {
module = 'suggestedAccounts'
} else if (item.type === 'feed') {
module = 'suggestedFeeds'
} else if (item.type === 'starterPack') {
module = 'suggestedStarterPacks'
} else if (item.type === 'preview:sliceItem') {
module = `feed:feedgen|${item.feed.uri}`
} else {
return
}
if (!alreadyReportedRef.current.has(module)) {
alreadyReportedRef.current.set(module, module)
logger.metric('explore:module:seen', {module})
}
}, [])
return (
<List
@@ -879,13 +973,30 @@ export function Explore({
keyboardDismissMode="on-drag"
stickyHeaderIndices={native(stickyHeaderIndices)}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
onItemSeen={onItemSeen}
onEndReached={onLoadMoreFeedPreviews}
onEndReachedThreshold={3}
initialNumToRender={initialNumToRender}
windowSize={9}
maxToRenderPerBatch={platform({ios: 5, default: 1})}
updateCellsBatchingPeriod={40}
/**
* Default: 2
*/
onEndReachedThreshold={4}
/**
* Default: 10
*/
initialNumToRender={10}
/**
* Default: 21
*/
windowSize={platform({android: 11})}
/**
* Default: 10
*/
maxToRenderPerBatch={platform({android: 1})}
/**
* Default: 50
*/
updateCellsBatchingPeriod={platform({android: 25})}
refreshing={isPTR}
onRefresh={onPTR}
/>
)
}
@@ -6,7 +6,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, type ViewStyleProp, web} from '#/alf'
import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {sizes as iconSizes} from '#/components/icons/common'
@@ -17,8 +17,11 @@ import {Text, type TextProps} from '#/components/Typography'
export function Container({
style,
children,
headerHeight,
}: {children: React.ReactNode; headerHeight?: number} & ViewStyleProp) {
bottomBorder,
}: {
children: React.ReactNode
bottomBorder?: boolean
} & ViewStyleProp) {
const t = useTheme()
return (
<View
@@ -30,11 +33,9 @@ export function Container({
a.pb_md,
a.gap_sm,
t.atoms.bg,
headerHeight && web({position: 'sticky', top: headerHeight}),
bottomBorder && [a.border_b, t.atoms.border_contrast_low],
style,
]}>
{/* Very non-scientific way to avoid small gap on scroll */}
<View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
{children}
</View>
)
@@ -19,6 +19,7 @@ import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useStarterPackLink} from '#/components/StarterPack/StarterPackCard'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
@@ -32,10 +33,11 @@ export function StarterPackCard({
const {currentAccount} = useSession()
const {gtPhone} = useBreakpoints()
const link = useStarterPackLink({view})
const record = view.record
if (
!bsky.dangerousIsType<AppBskyGraphStarterpack.Record>(
view.record,
record,
AppBskyGraphStarterpack.isRecord,
)
) {
@@ -48,75 +50,80 @@ export function StarterPackCard({
.map(item => item.subject)
return (
<View
style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<View aria-hidden style={[a.absolute, a.inset_0, a.z_40]}>
<Link
to={link.to}
label={link.label}
style={[a.absolute, a.inset_0]}
onHoverIn={link.precache}
onPress={link.precache}>
<View />
</Link>
</View>
<Link
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}>
{s => (
<>
<SubtleHover hover={s.hovered || s.pressed} />
<AvatarStack
profiles={profiles ?? []}
numPending={profileCount}
total={view.list?.listItemCount}
/>
<View
style={[
a.w_full,
a.p_lg,
a.gap_md,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<AvatarStack
profiles={profiles ?? []}
numPending={profileCount}
total={view.list?.listItemCount}
/>
<View
style={[
a.w_full,
a.flex_row,
a.align_start,
a.gap_lg,
web({
position: 'static',
zIndex: 'unset',
}),
]}>
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{view.record.name}
</Text>
<Text
emoji
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{view.creator?.did === currentAccount?.did
? _(msg`By you`)
: _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
</Text>
</View>
<Link
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}
variant="solid"
color="secondary"
size="small"
style={[a.z_50]}>
<ButtonText>
<Trans>Open pack</Trans>
</ButtonText>
</Link>
</View>
</View>
<View
style={[
a.w_full,
a.flex_row,
a.align_start,
a.gap_lg,
web({
position: 'static',
zIndex: 'unset',
}),
]}>
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{record.name}
</Text>
<Text
emoji
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{view.creator?.did === currentAccount?.did
? _(msg`By you`)
: _(msg`By ${sanitizeHandle(view.creator.handle, '@')}`)}
</Text>
</View>
<Link
to={link.to}
label={link.label}
onHoverIn={link.precache}
onPress={link.precache}
variant="solid"
color="secondary"
size="small"
style={[a.z_50]}>
<ButtonText>
<Trans>Open pack</Trans>
</ButtonText>
</Link>
</View>
</View>
</>
)}
</Link>
)
}
@@ -8,6 +8,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
@@ -39,32 +40,33 @@ export function ExploreInterestsCard() {
<>
<Prompt.Basic
control={trendingPrompt}
title={_(msg`Your interests`)}
title={_(msg`Dismiss interests`)}
description={_(
msg`You can adjust your interests at any time from your "Content and media" settings.`,
msg`You can adjust your interests at any time from "Content and media" settings.`,
)}
confirmButtonCta={_(
msg({
message: `Copy that!`,
comment: `Confirm button text. Can be a short cheeky phrase that means "OK" e.g. "Copy that!"`,
message: `OK`,
comment: `Confirm button text.`,
}),
)}
onConfirm={onConfirmClose}
/>
<View style={[a.p_lg, a.pb_2xs]}>
<View style={[a.pb_2xs]}>
<View
style={[
a.p_lg,
a.rounded_md,
a.border,
a.gap_sm,
a.border_b,
a.gap_md,
t.atoms.border_contrast_medium,
t.atoms.bg_contrast_25,
]}>
<Text style={[a.text_md, a.font_bold, a.leading_tight]}>
<Trans>Your interests</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<Shapes />
<Text style={[a.text_xl, a.font_bold, a.leading_tight]}>
<Trans>Your interests</Trans>
</Text>
</View>
{preferences?.interests?.tags &&
preferences.interests.tags.length > 0 ? (
@@ -76,12 +78,11 @@ export function ExploreInterestsCard() {
a.justify_center,
a.align_center,
a.rounded_full,
a.border,
t.atoms.border_contrast_medium,
t.atoms.bg_contrast_25,
a.px_lg,
{height: 32},
]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Text style={[a.text_sm, t.atoms.text_contrast_high]}>
{interestsDisplayNames[tag]}
</Text>
</View>
@@ -89,10 +90,8 @@ export function ExploreInterestsCard() {
</View>
) : null}
<Text style={[a.text_sm, a.leading_snug, a.pb_xs]}>
<Trans>
Your selected interests help us serve you content you care about.
</Trans>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>Your interests help us find what you like!</Trans>
</Text>
<Link
@@ -110,15 +109,15 @@ export function ExploreInterestsCard() {
<Button
label={_(msg`Hide this card`)}
size="small"
variant="solid"
variant="ghost"
color="secondary"
shape="round"
onPress={onClose}
style={[
a.absolute,
{top: a.pt_xs.paddingTop, right: a.pr_xs.paddingRight},
{top: a.pt_sm.paddingTop, right: a.pr_sm.paddingRight},
]}>
<ButtonIcon icon={X} />
<ButtonIcon icon={X} size="md" />
</Button>
</View>
</View>
@@ -17,6 +17,7 @@ import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import * as ProfileCard from '#/components/ProfileCard'
import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
@@ -57,9 +58,11 @@ export function useLoadEnoughProfiles({
export function SuggestedAccountsTabBar({
selectedInterest,
onSelectInterest,
hideDefaultTab,
}: {
selectedInterest: string | null
onSelectInterest: (interest: string | null) => void
hideDefaultTab?: boolean
}) {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
@@ -71,8 +74,10 @@ export function SuggestedAccountsTabBar({
return (
<BlockDrawerGesture>
<Tabs
interests={['all', ...interests]}
selectedInterest={selectedInterest || 'all'}
interests={hideDefaultTab ? interests : ['all', ...interests]}
selectedInterest={
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
}
onSelectTab={tab => {
logger.metric(
'explore:suggestedAccounts:tabPressed',
@@ -82,10 +87,14 @@ export function SuggestedAccountsTabBar({
onSelectInterest(tab === 'all' ? null : tab)
}}
hasSearchText={false}
interestsDisplayNames={{
all: _(msg`For You`),
...interestsDisplayNames,
}}
interestsDisplayNames={
hideDefaultTab
? interestsDisplayNames
: {
all: _(msg`For You`),
...interestsDisplayNames,
}
}
TabComponent={Tab}
contentContainerStyle={[
{
@@ -133,10 +142,7 @@ let Tab = ({
a.py_sm,
a.border,
active || hovered || pressed || focused
? [
t.atoms.bg_contrast_25,
{borderColor: t.atoms.bg_contrast_25.backgroundColor},
]
? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
: [t.atoms.bg, t.atoms.border_contrast_low],
]}>
<Text
@@ -186,47 +192,52 @@ let SuggestedProfileCard = ({
{statsig: true},
)
}}>
<View
style={[
a.w_full,
a.py_lg,
a.px_lg,
a.border_t,
t.atoms.border_contrast_low,
a.flex_1,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="ExploreSuggestedAccounts"
onFollow={() => {
logger.metric(
'suggestedUser:follow',
{
logContext: 'Explore',
location: 'Card',
recId,
position,
},
{statsig: true},
)
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</View>
{s => (
<>
<SubtleHover hover={s.hovered || s.pressed} />
<View
style={[
a.flex_1,
a.w_full,
a.py_lg,
a.px_lg,
a.border_t,
t.atoms.border_contrast_low,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
withIcon={false}
logContext="ExploreSuggestedAccounts"
onFollow={() => {
logger.metric(
'suggestedUser:follow',
{
logContext: 'Explore',
location: 'Card',
recId,
position,
},
{statsig: true},
)
}}
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</View>
</>
)}
</ProfileCard.Link>
)
}
@@ -17,6 +17,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame'
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
import {Link} from '#/components/Link'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
const TOPIC_COUNT = 5
@@ -28,10 +29,10 @@ export function ExploreTrendingTopics() {
}
function Inner() {
const {data: trending, error, isLoading} = useGetTrendsQuery()
const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery()
const noTopics = !isLoading && !error && !trending?.trends?.length
return isLoading ? (
return isLoading || isRefetching ? (
Array.from({length: TOPIC_COUNT}).map((__, i) => (
<TrendingTopicRowSkeleton key={i} withPosts={i === 0} />
))
@@ -92,25 +93,23 @@ export function TrendRow({
PressableComponent={Pressable}>
{({hovered, pressed}) => (
<>
<View
style={[
gutters,
a.w_full,
a.py_lg,
a.flex_row,
a.gap_2xs,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<SubtleHover hover={hovered || pressed} />
<View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}>
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.flex_row]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, {width: 20}]}>
style={[
a.text_md,
a.font_bold,
a.leading_tight,
{width: 20},
]}>
<Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'>
{rank}.
</Trans>
</Text>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
style={[a.text_md, a.font_bold, a.leading_tight]}
numberOfLines={1}>
{trend.displayName}
</Text>
@@ -0,0 +1,56 @@
import {useMemo} from 'react'
import {useActorSearchPaginated} from '#/state/queries/actor-search'
import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
/**
* Conditional hook, used in case a user is a non-english speaker, in which
* case we fall back to searching for users instead of our more curated set.
*/
export function useSuggestedUsers({
category = null,
search = false,
}: {
category?: string | null
/**
* If true, we'll search for users using the translated value of `category`,
* based on the user's "app language setting
*/
search?: boolean
}) {
const interestsDisplayNames = useInterestsDisplayNames()
const curated = useGetSuggestedUsersQuery({
enabled: !search,
category,
})
const searched = useActorSearchPaginated({
enabled: !!search,
// use user's app language translation for this value
query: category ? interestsDisplayNames[category] : '',
limit: 10,
})
return useMemo(() => {
if (search) {
return {
// we're not paginating right now
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
}
: undefined,
isLoading: searched.isLoading,
error: searched.error,
isRefetching: searched.isRefetching,
}
} else {
return {
data: curated.data,
isLoading: curated.isLoading,
error: curated.error,
isRefetching: curated.isRefetching,
}
}
}, [curated, searched, search])
}
+28 -3
View File
@@ -10,6 +10,9 @@ import {
usePreferencesQuery,
} from '#/state/queries/preferences'
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {createGetSuggestedFeedsQueryKey} from '#/state/queries/trending/useGetSuggestedFeedsQuery'
import {createGetSuggestedUsersQueryKey} from '#/state/queries/trending/useGetSuggestedUsersQuery'
import {createSuggestedStarterPacksQueryKey} from '#/state/queries/useSuggestedStarterPacksQuery'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
@@ -98,15 +101,37 @@ function Inner({
try {
await agent.setInterestsPref({tags: interests})
await qc.invalidateQueries({queryKey: preferencesQueryKey})
qc.setQueriesData(
{queryKey: preferencesQueryKey},
(old?: UsePreferencesQueryResponse) => {
if (!old) return old
old.interests.tags = interests
return old
},
)
await Promise.all([
await qc.resetQueries({
queryKey: createSuggestedStarterPacksQueryKey(),
}),
await qc.resetQueries({queryKey: createGetSuggestedFeedsQueryKey()}),
await qc.resetQueries({
queryKey: createGetSuggestedUsersQueryKey({}),
}),
])
Toast.show(
_(msg({message: 'Content preferences updated!', context: 'toast'})),
_(
msg({
message: 'Your interests have been updated!',
context: 'toast',
}),
),
)
} catch (error) {
Toast.show(
_(
msg({
message: 'Failed to save content prefefences.',
message: 'Failed to save your interests.',
context: 'toast',
}),
),
+1 -1
View File
@@ -17,7 +17,7 @@ import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
export const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
export const RQKEY_PAGINATED = (query: string, limit?: number) => [
RQKEY_ROOT_PAGINATED,
query,
+60 -14
View File
@@ -34,6 +34,41 @@ const RQKEY_ROOT = 'feed-previews'
const RQKEY = (feeds: string[]) => [RQKEY_ROOT, feeds]
const LIMIT = 8 // sliced to 6, overfetch to account for moderation
const PINNED_POST_URIS: Record<string, boolean> = {
// 📰 News
'at://did:plc:kkf4naxqmweop7dv4l2iqqf5/app.bsky.feed.post/3lgh27w2ngc2b':
true,
// Gardening
'at://did:plc:5rw2on4i56btlcajojaxwcat/app.bsky.feed.post/3kjorckgcwc27':
true,
// Web Development Trending
'at://did:plc:m2sjv3wncvsasdapla35hzwj/app.bsky.feed.post/3lfaw445axs22':
true,
// Anime & Manga EN
'at://did:plc:tazrmeme4dzahimsykusrwrk/app.bsky.feed.post/3knxx2gmkns2y':
true,
// 📽️ Film
'at://did:plc:2hwwem55ce6djnk6bn62cstr/app.bsky.feed.post/3llhpzhbq7c2g':
true,
// PopSky
'at://did:plc:lfdf4srj43iwdng7jn35tjsp/app.bsky.feed.post/3lbblgly65c2g':
true,
// Science
'at://did:plc:hu2obebw3nhfj667522dahfg/app.bsky.feed.post/3kl33otd6ob2s':
true,
// Birds! 🦉
'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.post/3lbg4r57yk22d':
true,
// Astronomy
'at://did:plc:xy2zorw2ys47poflotxthlzg/app.bsky.feed.post/3kyzye4lujs2w':
true,
// What's Cooking 🍽️
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3lfqhgvxbqc2q':
true,
// BookSky 💙📚 #booksky
'at://did:plc:geoqe3qls5mwezckxxsewys2/app.bsky.feed.post/3kgrm2rw5ww2e':
true,
}
export type FeedPreviewItem =
| {
@@ -73,6 +108,7 @@ export type FeedPreviewItem =
key: string
slice: FeedPostSlice
indexInSlice: number
feed: AppBskyFeedDefs.GeneratorView
showReplyTo: boolean
hideTopBorder: boolean
}
@@ -84,6 +120,7 @@ export type FeedPreviewItem =
export function useFeedPreviews(
feedsMaybeWithDuplicates: AppBskyFeedDefs.GeneratorView[],
isEnabled: boolean = true,
) {
const feeds = useMemo(
() =>
@@ -99,7 +136,7 @@ export function useFeedPreviews(
const {data: preferences} = usePreferencesQuery()
const userInterests = aggregateUserInterests(preferences)
const moderationOpts = useModerationOpts()
const enabled = feeds.length > 0
const enabled = feeds.length > 0 && isEnabled
const query = useInfiniteQuery({
enabled,
@@ -180,19 +217,24 @@ export function useFeedPreviews(
feedContext: item.feedContext,
reason: item.reason,
feedPostUri: item.feedPostUri,
items: item.items.slice(0, 6).map((subItem, i) => {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
uri: subItem.post.uri,
post: subItem.post,
record: subItem.record,
moderation: moderations[i],
parentAuthor: subItem.parentAuthor,
isParentBlocked: subItem.isParentBlocked,
isParentNotFound: subItem.isParentNotFound,
}
return feedPostSliceItem
}),
items: item.items
.slice(0, 6)
.filter(subItem => {
return !PINNED_POST_URIS[subItem.post.uri]
})
.map((subItem, i) => {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${item._reactKey}-${i}-${subItem.post.uri}`,
uri: subItem.post.uri,
post: subItem.post,
record: subItem.record,
moderation: moderations[i],
parentAuthor: subItem.parentAuthor,
isParentBlocked: subItem.isParentBlocked,
isParentNotFound: subItem.isParentNotFound,
}
return feedPostSliceItem
}),
}
if (slice.isIncompleteThread && slice.items.length >= 3) {
const beforeLast = slice.items.length - 2
@@ -202,6 +244,7 @@ export function useFeedPreviews(
key: slice.items[0]._reactKey,
slice: slice,
indexInSlice: 0,
feed: page.feed,
showReplyTo: false,
hideTopBorder: rowIndex === 0,
})
@@ -215,6 +258,7 @@ export function useFeedPreviews(
key: slice.items[beforeLast]._reactKey,
slice: slice,
indexInSlice: beforeLast,
feed: page.feed,
showReplyTo:
slice.items[beforeLast].parentAuthor?.did !==
slice.items[beforeLast].post.author.did,
@@ -225,6 +269,7 @@ export function useFeedPreviews(
key: slice.items[last]._reactKey,
slice: slice,
indexInSlice: last,
feed: page.feed,
showReplyTo: false,
hideTopBorder: false,
})
@@ -235,6 +280,7 @@ export function useFeedPreviews(
key: slice.items[i]._reactKey,
slice: slice,
indexInSlice: i,
feed: page.feed,
showReplyTo: i === 0,
hideTopBorder: i === 0 && rowIndex === 0,
})
+11 -11
View File
@@ -1,18 +1,18 @@
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
AppBskyUnspeccedGetPopularFeedGenerators,
type AppBskyActorDefs,
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type AppBskyUnspeccedGetPopularFeedGenerators,
AtUri,
moderateFeedGenerator,
RichText,
} from '@atproto/api'
import {
InfiniteData,
type InfiniteData,
keepPreviousData,
QueryClient,
QueryKey,
type QueryClient,
type QueryKey,
useInfiniteQuery,
useMutation,
useQuery,
@@ -28,7 +28,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes'
import {useModerationOpts} from '../preferences/moderation-opts'
import {FeedDescriptor} from './post-feed'
import {type FeedDescriptor} from './post-feed'
import {precacheResolvedUri} from './resolve-uri'
export type FeedSourceFeedInfo = {
@@ -203,12 +203,12 @@ export const KNOWN_AUTHED_ONLY_FEEDS = [
'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics', // the gram, by why
]
type GetPopularFeedsOptions = {limit?: number}
type GetPopularFeedsOptions = {limit?: number; enabled?: boolean}
export function createGetPopularFeedsQueryKey(
options?: GetPopularFeedsOptions,
) {
return ['getPopularFeeds', options]
return ['getPopularFeeds', options?.limit]
}
export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
@@ -237,7 +237,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
QueryKey,
string | undefined
>({
enabled: Boolean(moderationOpts),
enabled: Boolean(moderationOpts) && options?.enabled !== false,
queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
@@ -11,18 +11,17 @@ import {useAgent} from '#/state/session'
export const DEFAULT_LIMIT = 5
export const createGetTrendsQueryKey = () => ['suggested-feeds']
export const createGetSuggestedFeedsQueryKey = () => ['suggested-feeds']
export function useGetSuggestedFeedsQuery() {
export function useGetSuggestedFeedsQuery({enabled}: {enabled?: boolean}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const savedFeeds = preferences?.savedFeeds
return useQuery({
enabled: !!preferences,
refetchOnWindowFocus: true,
staleTime: STALE.MINUTES.ONE,
queryKey: createGetTrendsQueryKey(),
enabled: !!preferences && enabled !== false,
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedFeedsQueryKey(),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const {data} = await agent.app.bsky.unspecced.getSuggestedFeeds(
@@ -13,12 +13,17 @@ import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export type QueryProps = {category?: string | null}
export type QueryProps = {
category?: string | null
limit?: number
enabled?: boolean
}
export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users'
export const createGetSuggestedUsersQueryKey = (props: QueryProps) => [
getSuggestedUsersQueryKeyRoot,
...Object.values(props),
props.category,
props.limit,
]
export function useGetSuggestedUsersQuery(props: QueryProps) {
@@ -26,15 +31,15 @@ export function useGetSuggestedUsersQuery(props: QueryProps) {
const {data: preferences} = usePreferencesQuery()
return useQuery({
enabled: !!preferences,
refetchOnWindowFocus: true,
staleTime: STALE.MINUTES.ONE,
enabled: !!preferences && props.enabled !== false,
staleTime: STALE.MINUTES.THREE,
queryKey: createGetSuggestedUsersQueryKey(props),
queryFn: async () => {
const contentLangs = getContentLanguages().join(',')
const {data} = await agent.app.bsky.unspecced.getSuggestedUsers(
{
category: props.category ?? undefined,
limit: props.limit || 10,
},
{
headers: {
@@ -52,7 +57,7 @@ export function useGetSuggestedUsersQuery(props: QueryProps) {
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
): Generator<AppBskyActorDefs.ProfileView, void> {
const responses =
queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsers.OutputSchema>({
queryKey: [getSuggestedUsersQueryKeyRoot],
@@ -25,7 +25,6 @@ export function useGetTrendsQuery() {
return useQuery({
enabled: !!preferences,
refetchOnWindowFocus: true,
staleTime: STALE.MINUTES.THREE,
queryKey: createGetTrendsQueryKey(),
queryFn: async () => {
@@ -13,15 +13,14 @@ export const createSuggestedStarterPacksQueryKey = () => [
'suggested-starter-packs',
]
export function useSuggestedStarterPacksQuery() {
export function useSuggestedStarterPacksQuery({enabled}: {enabled?: boolean}) {
const agent = useAgent()
const {data: preferences} = usePreferencesQuery()
const contentLangs = getContentLanguages().join(',')
return useQuery({
enabled: !!preferences,
refetchOnWindowFocus: true,
staleTime: STALE.MINUTES.ONE,
enabled: !!preferences && enabled !== false,
staleTime: STALE.MINUTES.THREE,
queryKey: createSuggestedStarterPacksQueryKey(),
async queryFn() {
const {data} = await agent.app.bsky.unspecced.getSuggestedStarterPacks(
@@ -3,33 +3,88 @@ import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger'
import {device} from '#/storage'
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm'
export const DE_LABELER = 'did:plc:r55ow3tocux5kafs5dq445fy'
export const ADDITIONAL_LABELERS_MAP: {
export const BR_LABELER = 'did:plc:ekitcvx7uwnauoqy5oest3hm' // Brazil
export const DE_LABELER = 'did:plc:r55ow3tocux5kafs5dq445fy' // Germany
export const RU_LABELER = 'did:plc:crm2agcxvvlj6hilnjdc4hox' // Russia
export const UK_LABELER = 'did:plc:gvkp7euswjjrctjmqwhhfzif' // United Kingdom
export const AU_LABELER = 'did:plc:dsynw7isrf2eqlhcjx3ffnmt' // Australia
export const TR_LABELER = 'did:plc:cquoj7aozvmkud2gifeinkda' // Turkey
export const JP_LABELER = 'did:plc:vhgppeyjwgrr37vm4v6ggd5a' // Japan
export const ES_LABELER = 'did:plc:zlbbuj5nov4ixhvgl3bj47em' // Spain
export const PK_LABELER = 'did:plc:zrp6a3tvprrsgawsbswbxu7m' // Pakistan
export const IN_LABELER = 'did:plc:srr4rdvgzkbx6t7fxqtt6j5t' // India
/**
* For all EU countries
*/
export const EU_LABELER = 'did:plc:z57lz5dhgz2dkjogoysm3vut'
const MODERATION_AUTHORITIES: {
[countryCode: string]: string[]
} = {
BR: [BR_LABELER],
DE: [DE_LABELER],
BR: [BR_LABELER], // Brazil
RU: [RU_LABELER], // Russia
UK: [UK_LABELER], // United Kingdom
AU: [AU_LABELER], // Australia
TR: [TR_LABELER], // Turkey
JP: [JP_LABELER], // Japan
PK: [PK_LABELER], // Pakistan
IN: [IN_LABELER], // India
// EU countries
AT: [EU_LABELER], // Austria
BE: [EU_LABELER], // Belgium
BG: [EU_LABELER], // Bulgaria
HR: [EU_LABELER], // Croatia
CY: [EU_LABELER], // Cyprus
CZ: [EU_LABELER], // Czech Republic
DK: [EU_LABELER], // Denmark
EE: [EU_LABELER], // Estonia
FI: [EU_LABELER], // Finland
FR: [EU_LABELER], // France
DE: [EU_LABELER, DE_LABELER], // Germany
GR: [EU_LABELER], // Greece
HU: [EU_LABELER], // Hungary
IE: [EU_LABELER], // Ireland
IT: [EU_LABELER], // Italy
LV: [EU_LABELER], // Latvia
LT: [EU_LABELER], // Lithuania
LU: [EU_LABELER], // Luxembourg
MT: [EU_LABELER], // Malta
NL: [EU_LABELER], // Netherlands
PL: [EU_LABELER], // Poland
PT: [EU_LABELER], // Portugal
RO: [EU_LABELER], // Romania
SK: [EU_LABELER], // Slovakia
SI: [EU_LABELER], // Slovenia
ES: [EU_LABELER, ES_LABELER], // Spain
SE: [EU_LABELER], // Sweden
}
export const ALL_ADDITIONAL_LABELERS = Object.values(
ADDITIONAL_LABELERS_MAP,
).flat()
export const NON_CONFIGURABLE_LABELERS = [BR_LABELER, DE_LABELER]
const MODERATION_AUTHORITIES_DIDS = Array.from(
new Set(Object.values(MODERATION_AUTHORITIES).flat()),
)
export function isNonConfigurableModerationAuthority(did: string) {
return NON_CONFIGURABLE_LABELERS.includes(did)
return MODERATION_AUTHORITIES_DIDS.includes(did)
}
export function configureAdditionalModerationAuthorities() {
const geolocation = device.get(['geolocation'])
let additionalLabelers: string[] = ALL_ADDITIONAL_LABELERS
// default to all
let additionalLabelers: string[] = MODERATION_AUTHORITIES_DIDS
if (geolocation?.countryCode) {
additionalLabelers = ADDITIONAL_LABELERS_MAP[geolocation.countryCode] ?? []
// overwrite with only those necessary
additionalLabelers = MODERATION_AUTHORITIES[geolocation.countryCode] ?? []
} else {
logger.info(`no geolocation, cannot apply mod authorities`)
}
if (__DEV__) {
additionalLabelers = []
}
const appLabelers = Array.from(
new Set([...BskyAgent.appLabelers, ...additionalLabelers]),
)
+28 -8
View File
@@ -1,16 +1,16 @@
import React, {memo, useMemo} from 'react'
import {
GestureResponderEvent,
type GestureResponderEvent,
StyleSheet,
Text as RNText,
View,
} from 'react-native'
import {
AppBskyFeedDefs,
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
type AppBskyFeedThreadgate,
AtUri,
ModerationDecision,
type ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
@@ -26,9 +26,14 @@ import {countLines} from '#/lib/strings/helpers'
import {niceDate} from '#/lib/strings/time'
import {s} from '#/lib/styles'
import {getTranslatorLink, isPostInLanguage} from '#/locale/helpers'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {logger} from '#/logger'
import {
POST_TOMBSTONE,
type Shadow,
usePostShadow,
} from '#/state/cache/post-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {ThreadPost} from '#/state/queries/post-thread'
import {type ThreadPost} from '#/state/queries/post-thread'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
@@ -52,7 +57,7 @@ import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import {AppModerationCause} from '#/components/Pills'
import {type AppModerationCause} from '#/components/Pills'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {SubtleWebHover} from '#/components/SubtleWebHover'
@@ -747,14 +752,29 @@ function ExpandedPostDetails({
const {_, i18n} = useLingui()
const openLink = useOpenLink()
const isRootPost = !('reply' in post.record)
const langPrefs = useLanguagePrefs()
const onTranslatePress = React.useCallback(
(e: GestureResponderEvent) => {
e.preventDefault()
openLink(translatorUrl, true)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
return false
},
[openLink, translatorUrl],
[openLink, translatorUrl, langPrefs, post],
)
return (
@@ -7,11 +7,11 @@ import {
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
type AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
type AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -20,7 +20,10 @@ import {useNavigation} from '@react-navigation/native'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {shareText, shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
@@ -28,7 +31,7 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/post-shadow'
import {type Shadow} from '#/state/cache/post-shadow'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useLanguagePrefs} from '#/state/preferences'
@@ -85,6 +88,7 @@ import {
useReportDialogControl,
} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as bsky from '#/types/bsky'
import * as Toast from '../Toast'
let PostDropdownMenuItems = ({
@@ -248,7 +252,20 @@ let PostDropdownMenuItems = ({
const onPressTranslate = React.useCallback(async () => {
await openLink(translatorUrl, true)
}, [openLink, translatorUrl])
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
logger.metric('translate', {
sourceLanguages: post.record.langs ?? [],
targetLanguage: langPrefs.primaryLanguage,
textLength: post.record.text.length,
})
}
}, [openLink, translatorUrl, langPrefs, post])
const onHidePost = React.useCallback(() => {
hidePost({uri: postUri})
+574 -544
View File
File diff suppressed because it is too large Load Diff