Compare commits

..

48 Commits

Author SHA1 Message Date
Samuel Newman 9041b04484 simulate error state I saw one time 2026-02-11 12:04:28 +02:00
Samuel Newman a25bc8f249 Debounce thread preferences on leading edge, reduce to 2s (#9851)
* debounce on leading edge, reduce to 2s

* flush debounce on leave screen

* simplify by using useMutation properly
2026-02-10 23:41:04 -08:00
pfrazee 177a6e50bd Nightly source-language update 2026-02-11 03:12:22 +00:00
Samuel Newman 338016ed5c Android sheets edge to edge (#8342) 2026-02-10 21:01:58 +00:00
Samuel Newman e2058c8ecc Update starter pack message to clarify users joined Bluesky (#9849) 2026-02-10 15:37:48 +00:00
surfdude29 3cbded0269 fix typos in drafts NUX alt text (#9832) 2026-02-10 10:41:47 +00:00
Samuel Newman 5ea96736de rm unused @testing-library/jest-native (#9820) 2026-02-10 08:36:27 +00:00
pfrazee efed955003 Nightly source-language update 2026-02-10 03:12:38 +00:00
surfdude29 b8db36afb0 Add context for translators for "No one" string (#9844)
* add context for translators 'No one' option

* update ActivityPrivacySettings to add context for translators

* update translation context for 'No one' option
2026-02-09 15:37:04 -08:00
Samuel Newman 5642bc40bb bump version to v1.117.0 (#9847) 2026-02-09 15:14:46 -08:00
Samuel Newman 3be1f5caa9 Add attribution for starter pack follows (#9834) 2026-02-09 10:01:55 -06:00
pfrazee 18eea81e76 Nightly source-language update 2026-02-08 03:12:29 +00:00
Samuel Newman 917f099e26 [Drafts] Some bugs (#9833)
* Fix text input not updating

* Fix autofocus

* make placeholder text fainter

* Await invalidation

* Image only drafts

* tweaks to gif presentation

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-07 15:38:48 +00:00
pfrazee 6bfe758d2a Nightly source-language update 2026-02-06 03:06:58 +00:00
Spence Pope 18ab5d6f34 [APP-1823] Fix 'create account' / 'sign in' buttons stacking (#9830)
* ensure enough space for sign in buttons

* smallest possible adjustment

* smallest possible adjustment
2026-02-05 08:40:25 -08:00
Spence Pope dc3f01591f [APP-1794] respect mute words for live event feed banners (#9831)
* respect mute words for live event feed banners

* linting
2026-02-05 09:36:19 -06:00
pfrazee 423eddc474 Nightly source-language update 2026-02-05 03:07:42 +00:00
Darrin Loeliger 5c5b365ee9 Adding little message at the bottom of the drafts list (#9827)
* Add encouragement message to drafts list

Shows "So many thoughts, you should post one" at the bottom of the drafts list when user has more than 5 drafts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Use Text component and center-align encouragement message

- Switch from ButtonText to Text component for better styling
- Add text-center alignment to the message

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 16:40:16 -06:00
Eric Bailey 1584c2de48 intl (#9826) 2026-02-04 15:49:33 -06:00
Samuel Newman bb6ce83198 [Drafts] NUX (#9825)
* drafts NUX

* use "Drafts" string

* change button copy
2026-02-04 15:39:55 -06:00
Samuel Newman 3abc361800 Exempt gifs from active video system (#9824) 2026-02-04 12:37:09 -08:00
Samuel Newman e6c4a539ca align alt text behaviour between gif types (#9822) 2026-02-04 12:34:14 -08:00
Eric Bailey 965b17ff86 Update android splash colors (#9821) 2026-02-04 14:07:18 -06:00
Eric Bailey 546201c93c intl pull and extract:all (#9818) 2026-02-04 13:14:22 -06:00
Samuel Newman b3cbb3440a Make GIFs look like GIFs (#9809)
* set presentation field in record

* refer to it as a gif in the composer

* video player gif presentation style

* tweak badge

* only do the "manual loop" when absolutely necessary

* mute gifs

* reuse gif controls component for tenor gifs

* update media previews in notifications

* edit comment

* remove outdated prop
2026-02-04 13:05:17 -06:00
Samuel Newman da5c356fc7 Only treat animated gifs as videos, leave static gifs as images (#9814)
* only treat *animated* gifs as videos

* fix export
2026-02-04 13:00:29 -06:00
Eric Bailey 6f01503eac intl pull and extract:all (#9817) 2026-02-04 12:52:39 -06:00
pfrazee 24dbe799dc Nightly source-language update 2026-02-04 03:06:15 +00:00
Samuel Newman 3197cbcf36 disable saving drafts for replies (#9815) 2026-02-03 09:32:13 -08:00
Samuel Newman 017120b3e8 Add recId to suggestedUser:* events (#9764)
* add recId to onboarding

* add recId to follow dialog

* add recId to profile header suggestions

* add recId to feed interstitials, fix animation on native

* fix claude feedback

* fix yarn.lock ci
2026-02-03 10:42:12 -06:00
Samuel Newman 27d9462639 Add animation to content hider (#9812)
* add layout animation to contenthider

* add layout animation to posthider
2026-02-03 08:03:36 -08:00
Samuel Newman b2c95bc35a remove superjson (#9811) 2026-02-03 07:52:19 -08:00
pfrazee cbd48c855a Nightly source-language update 2026-02-03 03:09:12 +00:00
Samuel Newman 421c18ea7c fix image patch version (#9810) 2026-02-02 15:35:04 -08:00
Samuel Newman 26c84399dd Add light haptic feedback to Edit Profile button (#9787)
https://claude.ai/code/session_018ZjPcCUDPfTjQ5SGac3Zgs

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 15:33:55 -08:00
Samuel Newman 4f1d4821c5 Speed up startup by persisting some queries (#9594)
* persist startup queries

* Use IDB for query storage (#9687)

* Add storage abstraction for persisted query data

Introduce a platform-specific storage abstraction layer for react-query
persistence:
- Native: Uses MMKV for high-performance synchronous storage
- Web: Uses IndexedDB via the `idb` library for efficient async storage

This replaces the previous AsyncStorage implementation with more performant
platform-native solutions. The abstraction maintains API compatibility with
@tanstack/query-async-storage-persister.

* Refactor storage abstraction to use factory pattern

Change createPersistedQueryStorage to a factory function that accepts a
storage ID, allowing multiple isolated storage instances:
- Native: Each instance gets its own MMKV store
- Web: Each instance gets its own IndexedDB database

Adopt the factory pattern in:
- react-query.tsx: Uses 'persisted_queries' storage
- ageAssurance/data.tsx: Uses 'age_assurance' storage

This provides better separation between different query client caches
and allows each to be managed independently.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* Refactor to use archival storage

(cherry picked from commit a773b40e41c96f821cd32260919ce1437c0fc3ab)

* Improve archive db types

(cherry picked from commit 80e4959ba2aa00c984c26aed2f7dfae1095720b0)

* rm idb

* clear on logout, bust on app version

* create abstraction for persisting queries, make gcTime infinite

* Rm abstraction

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-02 11:55:01 -06:00
mozzius bfd5ed480f Nightly source-language update 2026-02-01 10:24:34 +00:00
Samuel Newman d9ef02737b Rename "lightbox" to "image viewer" in user-facing strings (#9807)
https://claude.ai/code/session_01LWKpUAY5WJL7UfeFJd9kVU

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 12:21:15 +02:00
mozzius 809b318fe4 Nightly source-language update 2026-02-01 08:10:57 +00:00
Samuel Newman 864af57fd9 Fix i18n string interpolation for device name (#9806)
Use a separate translatable string for "Media stored on another device"
instead of interpolating a translated fragment into another string.
This allows translators to properly handle the complete sentence for
languages with different grammar structures.

https://claude.ai/code/session_01TJMLcXE9HHneEXMEBJqL4u

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 00:08:28 -08:00
Eric Bailey ab98130295 Rm entitlement for now (#9804) 2026-01-30 20:30:00 -06:00
estrattonbailey a168e97d07 Nightly source-language update 2026-01-31 02:20:20 +00:00
Eric Bailey 748706daab Draft previews (#9803)
* Send deviceId and platform

* Add deviceId and deviceName to drafts, skip loading media for other devies

* WIP new preview

* show rich text in drafts list

(cherry picked from commit fb70d53d59)

* New draft preview UI

* Tighten up spacing in draft list

* Add i18n comments

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-30 17:00:54 -06:00
Spence Pope 45d6e50eb4 lint (#9802) 2026-01-30 22:48:31 +02:00
Eric Bailey e2a56b0191 Drafts fixes (#9795)
* Allow saving quote post drafts

* Remove warning logs for failed media loading

* Fix threadgates
2026-01-30 10:26:32 -06:00
Samuel Newman 1eb09b7210 Detect facets when composer is opened with mention (#9625)
* highlight the initial mention

* fix mention initalisation on web
2026-01-30 10:14:37 -06:00
Samuel Newman ee99320e1f fix copy-to-cache (#9799) 2026-01-30 09:47:35 -06:00
Samuel Newman 9689a6daaa add 2px margin below post text (#9748) 2026-01-30 05:44:04 -08:00
142 changed files with 31038 additions and 24172 deletions
+5 -3
View File
@@ -29,8 +29,9 @@ yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it)
yarn intl:compile # Compile translations for runtime
# DO NOT run these commands - extraction and compilation are handled by CI
yarn intl:extract # Extract translation strings (nightly CI job)
yarn intl:compile # Compile translations for runtime (nightly CI job)
# Build
yarn build-web # Build web version
@@ -299,8 +300,9 @@ function MyComponent() {
**Commands:**
```bash
# DO NOT run these commands - extraction and compilation are handled by a nightly CI job
yarn intl:extract # Extract new strings to locale files
yarn intl:compile # Compile for runtime (required after changes)
yarn intl:compile # Compile translations for runtime
```
## State Management
+5 -4
View File
@@ -119,6 +119,7 @@ module.exports = function (_config) {
'com.apple.developer.kernel.increased-memory-limit': true,
'com.apple.developer.kernel.extended-virtual-addressing': true,
'com.apple.security.application-groups': 'group.app.bsky',
// 'com.apple.developer.device-information.user-assigned-device-name': true,
},
privacyManifests: {
NSPrivacyCollectedDataTypes: [
@@ -312,22 +313,22 @@ module.exports = function (_config) {
{
ios: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/splash.png',
resizeMode: 'cover',
dark: {
enableFullScreenImage_legacy: true, // iOS only
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/splash-dark.png',
resizeMode: 'cover',
},
},
android: {
backgroundColor: '#A8CCFF', // primary_200
backgroundColor: '#006AFF', // primary_500
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102, // even division of 306px
dark: {
backgroundColor: '#00398A', // primary_800
backgroundColor: '#002861', // primary_900
image: './assets/splash/android-splash-logo-white.png',
imageWidth: 102,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

-1
View File
@@ -1,4 +1,3 @@
import '@expo/metro-runtime'
import '#/platform/markBundleStartTime'
import '#/platform/polyfills'
+1 -1
View File
@@ -44,6 +44,6 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.google.android.material:material:1.13.0'
implementation "com.facebook.react:react-native:+"
}
@@ -5,8 +5,12 @@ import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.Window
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
@@ -15,6 +19,7 @@ import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.internal.EdgeToEdgeUtils
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
@@ -29,22 +34,26 @@ class BottomSheetView(
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private var isKeyboardVisible: Boolean = false
private val rawScreenHeight =
private val screenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val safeScreenHeight = (rawScreenHeight - getNavigationBarHeight()).toFloat()
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private fun getStatusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
}
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set(value) {
field = value
@@ -56,48 +65,31 @@ class BottomSheetView(
field = value
this.dialog?.setCancelable(!value)
}
var preventExpansion = false
var minHeight = 0f
set(value) {
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.safeScreenHeight
var maxHeight = this.screenHeight
set(value) {
val px = dpToPx(value)
field =
if (px > this.safeScreenHeight) {
this.safeScreenHeight
} else {
px
}
field = if (px > this.screenHeight) this.screenHeight else px
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
onStateChange(mapOf("state" to if (value) "open" else "closed"))
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "opening",
),
)
onStateChange(mapOf("state" to "opening"))
}
}
@@ -105,33 +97,21 @@ class BottomSheetView(
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "closing",
),
)
onStateChange(mapOf("state" to "closing"))
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
onSnapPointChange(mapOf("snapPoint" to value))
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
@@ -161,27 +141,55 @@ class BottomSheetView(
private fun getHalfExpandedRatio(contentHeight: Float): Float =
when {
// Full height sheets
contentHeight >= safeScreenHeight -> 0.99f
// Medium height sheets (>50% but <100%)
contentHeight >= safeScreenHeight / 2 ->
this.clampRatio(this.getTargetHeight() / safeScreenHeight)
// Small height sheets (<50%)
else ->
this.clampRatio(this.getTargetHeight() / rawScreenHeight)
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
val dialog = BottomSheetDialog(context)
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
if (currentContext is android.app.Activity) {
activityWindow = currentContext.window
break
}
currentContext = (currentContext as? android.content.ContextWrapper)?.baseContext
}
val originalStatusBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars
}
val originalNavBarAppearance =
activityWindow?.let { window ->
WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars
}
val dialog = BottomSheetDialog(context, R.style.EdgeToEdgeBottomSheetDialogTheme)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setDismissWithAnimation(true)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
dialog.setOnShowListener {
dialog.window?.let { window ->
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (originalNavBarAppearance != null) {
insetsController.isAppearanceLightNavigationBars = originalNavBarAppearance
}
if (originalStatusBarAppearance != null) {
EdgeToEdgeUtils.setLightStatusBar(window, originalStatusBarAppearance)
}
}
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
@@ -194,7 +202,17 @@ class BottomSheetView(
behavior.isDraggable = true
behavior.isHideable = true
if (contentHeight >= this.safeScreenHeight || this.minHeight >= this.safeScreenHeight) {
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
} else {
behavior.maxHeight = (screenHeight - getStatusBarHeight()).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (shouldBeExpanded) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
@@ -209,18 +227,10 @@ class BottomSheetView(
newState: Int,
) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
BottomSheetBehavior.STATE_EXPANDED -> selectedSnapPoint = 2
BottomSheetBehavior.STATE_COLLAPSED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HALF_EXPANDED -> selectedSnapPoint = 1
BottomSheetBehavior.STATE_HIDDEN -> selectedSnapPoint = 0
}
}
@@ -231,9 +241,26 @@ class BottomSheetView(
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
ViewCompat.setOnApplyWindowInsetsListener(dialogRootViewGroup) { view, insets ->
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
val behavior = bottomSheet?.let { BottomSheetBehavior.from(it) }
val wasKeyboardVisible = isKeyboardVisible
isKeyboardVisible = imeVisible
if (imeVisible && behavior?.state == BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (!imeVisible && wasKeyboardVisible) {
updateLayout()
}
insets
}
}
fun updateLayout() {
@@ -246,12 +273,24 @@ class BottomSheetView(
val currentState = behavior.state
val oldRatio = behavior.halfExpandedRatio
var newRatio = getHalfExpandedRatio(contentHeight)
val newRatio = getHalfExpandedRatio(contentHeight)
behavior.halfExpandedRatio = newRatio
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
if (preventExpansion) {
behavior.maxHeight = (behavior.halfExpandedRatio * screenHeight).toInt()
}
val targetHeight = this.getTargetHeight()
val availableHeight = screenHeight - getStatusBarHeight() - getNavigationBarHeight()
val shouldBeExpanded = targetHeight >= availableHeight
if (isKeyboardVisible) {
if (behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
} else if (shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_EXPANDED && !preventExpansion) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
} else if (!shouldBeExpanded && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
@@ -279,25 +318,19 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
return when {
contentHeight > maxHeight -> maxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
}
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
private fun clampRatio(ratio: Float): Float =
when {
ratio < 0.01 -> 0.01f
ratio > 0.99 -> 0.99f
else -> ratio
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
@@ -322,9 +355,7 @@ class BottomSheetView(
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
if (structure == null) {
return
}
if (structure == null) return
dialogRootViewGroup.dispatchProvideStructure(structure)
}
@@ -363,7 +394,6 @@ class BottomSheetView(
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
return dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
}
}
@@ -52,6 +52,8 @@ class DialogRootViewGroup(
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
fitsSystemWindows = false
}
override fun onSizeChanged(
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="EdgeToEdgeBottomSheetDialogTheme" parent="Theme.Material3.DayNight.BottomSheetDialog">
<!-- Enable edge-to-edge -->
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:windowIsFloating">false</item>
<item name="enableEdgeToEdge">true</item>
<!-- Configure bottom sheet to respect system window insets -->
<item name="bottomSheetStyle">@style/EdgeToEdgeBottomSheet</item>
</style>
<style name="EdgeToEdgeBottomSheet" parent="Widget.Material3.BottomSheet">
<item name="paddingBottomSystemWindowInsets">false</item>
<item name="paddingLeftSystemWindowInsets">true</item>
<item name="paddingRightSystemWindowInsets">true</item>
<item name="paddingTopSystemWindowInsets">false</item>
</style>
</resources>
@@ -175,6 +175,7 @@ function BottomSheetNativeComponentInner({
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
overflow: 'hidden',
},
extraStyles,
]}>
@@ -34,12 +34,15 @@ class NotificationPrefs(
is Boolean -> {
putBoolean(key, value)
}
is String -> {
putString(key, value)
}
is Array<*> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
is Map<*, *> -> {
putStringSet(key, value.map { it.toString() }.toSet())
}
@@ -117,7 +117,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleImageIntents(
uris: List<Uri>,
text: String?
text: String?,
) {
var allParams = ""
@@ -145,7 +145,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleVideoIntents(
uris: List<Uri>,
text: String?
text: String?,
) {
val uri = uris[0]
// If there is no extension for the file, substringAfterLast returns the original string - not
+4 -6
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.116.0",
"version": "1.117.0",
"private": true,
"engines": {
"node": ">=20"
@@ -73,7 +73,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.18",
"@atproto/api": "^0.18.20",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
@@ -202,7 +202,7 @@
"react-native-edge-to-edge": "^1.6.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-get-random-values": "~1.11.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-keyboard-controller": "^1.20.7",
"react-native-pager-view": "6.8.0",
"react-native-progress": "bluesky-social/react-native-progress",
"react-native-qrcode-styled": "^0.3.3",
@@ -229,13 +229,12 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.206",
"@atproto/dev-env": "^0.3.208",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
"@eslint/js": "^9.39.2",
"@expo/config-plugins": "~54.0.1",
"@expo/metro-runtime": "~6.1.2",
"@lingui/cli": "^4.14.1",
"@lingui/macro": "^4.14.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
@@ -243,7 +242,6 @@
"@react-native/eslint-config": "^0.81.5",
"@react-native/typescript-config": "^0.81.5",
"@sentry/webpack-plugin": "^3.2.2",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.2.0",
"@types/jest": "29.5.14",
"@types/lodash.chunk": "^4.2.7",
+1 -1
View File
@@ -3,6 +3,7 @@ import '#/view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
import {KeyboardProvider as KeyboardControllerProvider} from 'react-native-keyboard-controller'
import {
initialWindowMetrics,
SafeAreaProvider,
@@ -14,7 +15,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {s} from '#/lib/styles'
+2 -1
View File
@@ -146,7 +146,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
() => {
'worklet'
// set these values to check animation at specific point
outroLogo.set(() =>
withTiming(
+2 -2
View File
@@ -6,7 +6,6 @@ import {
AtpAgent,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
@@ -14,6 +13,7 @@ import debounce from 'lodash.debounce'
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {getAge} from '#/lib/strings/time'
import {
hasSnoozedBirthdateUpdateForDid,
@@ -45,7 +45,7 @@ const qc = new QueryClient({
},
})
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
storage: createPersistedQueryStorage('age-assurance'),
key: 'age-assurance-query-client',
})
const [, cacheHydrationPromise] = persistQueryClient({
+5 -5
View File
@@ -467,8 +467,8 @@ export type Events = {
| 'InterstitialProfile'
| 'Profile'
| 'Onboarding'
location: 'Card' | 'Profile'
recId?: number
location: 'Card' | 'Profile' | 'FollowAll'
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -479,7 +479,7 @@ export type Events = {
| 'InterstitialDiscover'
| 'InterstitialProfile'
| 'Onboarding'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -492,7 +492,7 @@ export type Events = {
| 'Profile'
| 'Onboarding'
| 'ProgressGuide'
recId?: number
recId?: number | string
position: number
suggestedDid: string
category: string | null
@@ -507,7 +507,7 @@ export type Events = {
}
'suggestedUser:dismiss': {
logContext: 'InterstitialDiscover' | 'InterstitialProfile'
recId?: number
recId?: number | string
position: number
suggestedDid: string
}
+3 -7
View File
@@ -11,6 +11,7 @@ import {
} from 'react-native'
import {
KeyboardAwareScrollView,
type KeyboardAwareScrollViewRef,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
@@ -23,7 +24,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useEnableKeyboardController} from '#/lib/hooks/useEnableKeyboardController'
import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger'
import {useA11y} from '#/state/a11y'
@@ -209,10 +209,9 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
useEnableKeyboardController(IS_IOS)
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
// note: iOS-only. keyboard-controller doesn't seem to work inside the sheets on Android
useKeyboardHandler(
{
onEnd: e => {
@@ -231,7 +230,6 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
}
paddingBottom = Math.max(paddingBottom, tokens.space._2xl)
} else {
paddingBottom += keyboardHeight
if (nativeSnapPoint === BottomSheetSnapPoint.Full) {
paddingBottom += insets.top
}
@@ -259,7 +257,7 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
{paddingBottom},
contentContainerStyle,
]}
ref={ref}
ref={ref as React.Ref<KeyboardAwareScrollViewRef>}
showsVerticalScrollIndicator={IS_ANDROID ? false : undefined}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
@@ -289,8 +287,6 @@ export const InnerFlatList = React.forwardRef<
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
useEnableKeyboardController(IS_IOS)
const onScroll = (e: ScrollEvent) => {
'worklet'
if (!IS_ANDROID) {
+101 -120
View File
@@ -1,6 +1,12 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import Animated, {
Easing,
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
} from 'react-native-reanimated'
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -21,6 +27,7 @@ import {type SeenPost} from '#/state/userActionHistory'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {
atoms as a,
native,
useBreakpoints,
useTheme,
type ViewStyleProp,
@@ -152,7 +159,7 @@ function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const dids = useMemo(() => {
const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
@@ -225,67 +232,54 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = React.useCallback((dismissedDid: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(dismissedDid))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(dismissedDid)
return next
})
}, DISMISS_ANIMATION_DURATION)
const onDismiss = useCallback((dismissedDid: string) => {
setDismissedDids(prev => new Set(prev).add(dismissedDid))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== did) {
seen.add(profile.did)
if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, did])
}, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -301,11 +295,9 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profile"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -327,46 +319,36 @@ export function SuggestedFollowsHome() {
error: suggestionsError,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = React.useCallback((did: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(did))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(did))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(did)
return next
})
}, DISMISS_ANIMATION_DURATION)
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from experimental query with paginated suggestions
const allProfiles = React.useMemo(() => {
const allProfiles = useMemo(() => {
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring experimental profiles
const seen = new Set<string>()
const combined: bsky.profile.AnyProfileView[] = []
const combined: Array<{
actor: bsky.profile.AnyProfileView
recId?: number
}> = []
for (const profile of experimentalProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
combined.push({actor: profile, recId: undefined})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
if (!seen.has(profile.actor.did)) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
@@ -374,19 +356,19 @@ export function SuggestedFollowsHome() {
return combined
}, [experimentalProfiles, moreSuggestions?.pages])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
void fetchNextPage()
}
}, [
filteredProfiles.length,
@@ -405,7 +387,6 @@ export function SuggestedFollowsHome() {
error={experimentalError || suggestionsError}
viewContext="feed"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
@@ -415,18 +396,14 @@ export function ProfileGrid({
error,
profiles,
totalProfileCount,
recId,
viewContext = 'feed',
onDismiss,
dismissingDids,
isVisible = true,
}: {
isSuggestionsLoading: boolean
profiles: bsky.profile.AnyProfileView[]
profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
totalProfileCount?: number
recId?: number
error: Error | null
dismissingDids?: Set<string>
viewContext: 'profile' | 'profileHeader' | 'feed'
onDismiss?: (did: string) => void
isVisible?: boolean
@@ -463,18 +440,18 @@ export function ProfileGrid({
const profilesToShow = profiles.slice(0, maxLength)
profilesToShow.forEach((profile, index) => {
if (!seenProfilesRef.current.has(profile.did)) {
seenProfilesRef.current.add(profile.did)
if (!seenProfilesRef.current.has(profile.actor.did)) {
seenProfilesRef.current.add(profile.actor.did)
ax.metric('suggestedUser:seen', {
logContext,
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}
})
}, [ax, isLoading, error, profiles, maxLength, logContext, recId])
}, [ax, isLoading, error, profiles, maxLength, logContext])
// For profile header, fire when isVisible becomes true
useEffect(() => {
@@ -540,8 +517,15 @@ export function ProfileGrid({
? null
: profiles.slice(0, maxLength).map((profile, index) => (
<Animated.View
key={profile.did}
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
key={profile.actor.did}
layout={native(
LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
Easing.out(Easing.exp),
),
)}
exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
// for web, as the cards are static, not in a list
entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
style={[
a.flex_1,
gtMobile &&
@@ -550,22 +534,17 @@ export function ProfileGrid({
a.flex_grow,
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
]),
{
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
transitionProperty: 'opacity',
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
},
]}>
<ProfileCard.Link
profile={profile}
profile={profile.actor}
onPress={() => {
ax.metric('suggestedUser:press', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -581,14 +560,14 @@ export function ProfileGrid({
label={_(msg`Dismiss this suggestion`)}
onPress={e => {
e.preventDefault()
onDismiss(profile.did)
onDismiss(profile.actor.did)
ax.metric('suggestedUser:dismiss', {
logContext: isFeedContext
? 'InterstitialDiscover'
: 'InterstitialProfile',
position: index,
suggestedDid: profile.did,
recId,
suggestedDid: profile.actor.did,
recId: profile.recId,
})
}}
style={[
@@ -621,18 +600,18 @@ export function ProfileGrid({
a.mb_auto,
]}>
<ProfileCard.Avatar
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
disabledPreview
size={88}
/>
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
<ProfileCard.Name
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
/>
<ProfileCard.Description
profile={profile}
profile={profile.actor}
numberOfLines={2}
style={[
t.atoms.text_contrast_medium,
@@ -644,7 +623,7 @@ export function ProfileGrid({
</View>
<ProfileCard.FollowButton
profile={profile}
profile={profile.actor}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
withIcon={false}
@@ -655,9 +634,9 @@ export function ProfileGrid({
? 'InterstitialDiscover'
: 'InterstitialProfile',
location: 'Card',
recId,
recId: profile.recId,
position: index,
suggestedDid: profile.did,
suggestedDid: profile.actor.did,
category: null,
})
}}
@@ -726,35 +705,37 @@ export function ProfileGrid({
<FollowDialogWithoutGuide control={followDialogControl} />
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
<LayoutAnimationConfig skipExiting skipEntering>
{gtMobile ? (
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
</View>
</View>
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
) : (
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
{content}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
{!isProfileHeaderContext && (
<SeeMoreSuggestedProfilesCard
onPress={() => {
followDialogControl.open()
ax.metric('suggestedUser:seeMore', {
logContext: 'Explore',
})
}}
/>
)}
</ScrollView>
</BlockDrawerGesture>
)}
</LayoutAnimationConfig>
</View>
)
}
@@ -795,7 +776,7 @@ export function SuggestedFeeds() {
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const feeds = useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
+26 -20
View File
@@ -50,7 +50,11 @@ export function Embed({
} else if (e.type === 'video') {
return (
<Outer style={style}>
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
{e.view.presentation === 'gif' ? (
<GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
) : (
<VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} />
)}
</Outer>
)
} else if (
@@ -81,11 +85,29 @@ export function ImageItem({
alt,
children,
}: {
thumbnail: string
thumbnail?: string
alt?: string
children?: React.ReactNode
}) {
const t = useTheme()
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
a.rounded_xs,
]}
accessibilityLabel={alt}
accessibilityHint="">
{children}
</View>
)
}
return (
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<Image
@@ -103,7 +125,7 @@ export function ImageItem({
)
}
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
export function GifItem({thumbnail, alt}: {thumbnail?: string; alt?: string}) {
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
@@ -125,22 +147,6 @@ export function VideoItem({
thumbnail?: string
alt?: string
}) {
if (!thumbnail) {
return (
<View
style={[
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth: 100},
a.justify_center,
a.align_center,
a.rounded_xs,
]}>
<PlayButtonIcon size={24} />
</View>
)
}
return (
<ImageItem thumbnail={thumbnail} alt={alt}>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
@@ -157,7 +163,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
left: 5,
bottom: 5,
zIndex: 2,
},
+8 -128
View File
@@ -1,76 +1,17 @@
import {useRef, useState} from 'react'
import {
Pressable,
type StyleProp,
StyleSheet,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
import {useAutoplayDisabled} from '#/state/preferences'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {Fill} from '#/components/Fill'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {IS_WEB} from '#/env'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {type GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
function PlaybackControls({
onPress,
isPlaying,
isLoaded,
}: {
onPress: () => void
isPlaying: boolean
isLoaded: boolean
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Pressable
accessibilityRole="button"
accessibilityHint={_(msg`Plays or pauses the GIF`)}
accessibilityLabel={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
!isLoaded && a.border,
t.atoms.border_contrast_medium,
a.inset_0,
a.w_full,
a.h_full,
{
zIndex: 2,
backgroundColor: !isLoaded
? t.atoms.bg_contrast_25.backgroundColor
: undefined,
},
]}
onPress={onPress}>
{!isLoaded ? (
<View>
<View style={[a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</View>
) : !isPlaying ? (
<PlayButtonIcon />
) : undefined}
</Pressable>
)
}
import {GifPresentationControls} from '../VideoEmbed/GifPresentationControls'
export function GifEmbed({
params,
@@ -120,8 +61,6 @@ export function GifEmbed({
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{backgroundColor: t.palette.black},
{aspectRatio},
style,
@@ -139,10 +78,12 @@ export function GifEmbed({
right: -2,
},
]}>
<PlaybackControls
<MediaInsetBorder />
<GifPresentationControls
onPress={onPress}
isPlaying={playerState.isPlaying}
isLoaded={playerState.isLoaded}
isLoading={!playerState.isLoaded}
altText={!hideAlt && isPreferredAltText ? altText : undefined}
/>
<GifView
source={params.playerUri}
@@ -164,68 +105,7 @@ export function GifEmbed({
]}
/>
)}
{!hideAlt && isPreferredAltText && <AltText text={altText} />}
</View>
</View>
)
}
function AltText({text}: {text: string}) {
const control = Prompt.usePromptControl()
const largeAltBadge = useLargeAltBadgeEnabled()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altContainer}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: IS_WEB ? 8 : 6,
paddingVertical: IS_WEB ? 6 : 3,
position: 'absolute',
// Related to margin/gap hack. This keeps the alt label in the same position
// on all platforms
right: IS_WEB ? 8 : 5,
bottom: IS_WEB ? 8 : 5,
zIndex: 2,
},
alt: {
color: 'white',
fontSize: IS_WEB ? 10 : 7,
fontWeight: '600',
},
})
@@ -0,0 +1,136 @@
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function GifPresentationControls({
onPress,
isPlaying,
isLoading,
altText,
}: {
onPress: () => void
isPlaying: boolean
isLoading?: boolean
altText?: string
}) {
const {_} = useLingui()
const t = useTheme()
return (
<>
<Button
label={isPlaying ? _(msg`Pause GIF`) : _(msg`Play GIF`)}
accessibilityHint={_(msg`Plays or pauses the GIF`)}
style={[
a.absolute,
a.align_center,
a.justify_center,
a.inset_0,
{zIndex: 2},
]}
onPress={onPress}>
{isLoading ? (
<View style={[a.align_center, a.justify_center]}>
<ActivityIndicator size="large" color="white" />
</View>
) : !isPlaying ? (
<PlayButtonIcon />
) : (
<></>
)}
</Button>
{!isPlaying && (
<Fill
style={[
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
{
opacity: 0.2,
zIndex: 1,
},
]}
/>
)}
<View style={styles.gifBadgeContainer}>
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
<Trans>GIF</Trans>
</Text>
</View>
{altText && <AltBadge text={altText} />}
</>
)
}
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
gifBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
left: 6,
bottom: 6,
zIndex: 2,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
},
})
@@ -15,6 +15,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {GifPresentationControls} from '../GifPresentationControls'
import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({
@@ -50,12 +51,14 @@ export function VideoEmbedInnerNative({
throw new Error(error)
}
const isGif = embed.presentation === 'gif'
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={autoplayDisabled ? false : muted}
beginMuted={isGif || autoplayDisabled ? false : muted}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
@@ -82,25 +85,36 @@ export function VideoEmbedInnerNative({
}
accessibilityHint=""
/>
<VideoControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
{isGif ? (
<GifPresentationControls
onPress={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
isLoading={false}
altText={embed.alt}
/>
) : (
<VideoPresentationControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
)}
<MediaInsetBorder />
</View>
)
}
function VideoControls({
function VideoPresentationControls({
enterFullscreen,
toggleMuted,
togglePlayback,
@@ -21,7 +21,7 @@ export function VideoEmbedInnerWeb({
active: boolean
setActive: () => void
onScreen: boolean
lastKnownTime: React.MutableRefObject<number | undefined>
lastKnownTime: React.RefObject<number | undefined>
}) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({
throw error
}
const hlsRef = useHLS({
const {hlsRef, loop} = useHLS({
playlist: embed.playlist,
setHasSubtitleTrack,
setError,
@@ -64,11 +64,12 @@ export function VideoEmbedInnerWeb({
style={{width: '100%', height: '100%', objectFit: 'contain'}}
playsInline
preload="none"
muted={!focused}
muted={embed.presentation === 'gif' || !focused}
aria-labelledby={embed.alt ? figId : undefined}
onTimeUpdate={e => {
lastKnownTime.current = e.currentTarget.currentTime
}}
loop={loop}
/>
{embed.alt && (
<figcaption
@@ -99,6 +100,8 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
altText={embed.alt}
/>
</div>
</View>
@@ -192,29 +195,6 @@ function useHLS({
},
)
const flushOnLoop = useNonReactiveCallback(() => {
if (!Hls) return
if (!hlsRef.current) return
const hls = hlsRef.current
// the above callback will catch most stale frags, but there's a corner case -
// if there's only one segment in the video, it won't get flushed because it avoids
// flushing the currently active segment. Therefore, we have to catch it when we loop
if (
hls.nextAutoLevel > 0 &&
lowQualityFragments.length === 1 &&
lowQualityFragments[0].start === 0
) {
const lowQualFrag = lowQualityFragments[0]
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
startOffset: lowQualFrag.start,
endOffset: lowQualFrag.end,
type: 'video',
})
setLowQualityFragments([])
}
})
useEffect(() => {
if (!videoRef.current) return
if (!Hls) return
@@ -242,20 +222,6 @@ function useHLS({
hls.attachMedia(videoRef.current)
hls.loadSource(playlist)
// manually loop, so if we've flushed the first buffer it doesn't get confused
const abortController = new AbortController()
const {signal} = abortController
const videoNode = videoRef.current
videoNode.addEventListener(
'ended',
() => {
flushOnLoop()
videoNode.currentTime = 0
videoNode.play()
},
{signal},
)
hls.on(Hls.Events.FRAG_LOADED, () => {
BandwidthEstimate.set(hls.bandwidthEstimate)
})
@@ -293,17 +259,65 @@ function useHLS({
hlsRef.current = undefined
hls.detachMedia()
hls.destroy()
}
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
const flushOnLoop = useNonReactiveCallback(() => {
if (!Hls) return
if (!hlsRef.current) return
const hls = hlsRef.current
// `handleFragChange` will catch most stale frags, but there's a corner case -
// if there's only one segment in the video, it won't get flushed because it avoids
// flushing the currently active segment. Therefore, we have to catch it when we loop
if (
hls.nextAutoLevel > 0 &&
lowQualityFragments.length === 1 &&
lowQualityFragments[0].start === 0
) {
const lowQualFrag = lowQualityFragments[0]
hls.trigger(Hls.Events.BUFFER_FLUSHING, {
startOffset: lowQualFrag.start,
endOffset: lowQualFrag.end,
type: 'video',
})
setLowQualityFragments([])
}
})
// manually loop, so if we've flushed the first buffer it doesn't get confused
const hasLowQualityFragmentAtStart = lowQualityFragments.some(
frag => frag.start === 0,
)
useEffect(() => {
if (!videoRef.current) return
// use `loop` prop on `<video>` element if the starting frag is high quality.
// otherwise, we need to do it with an event listener as we may need to manually flush the frag
if (!hasLowQualityFragmentAtStart) return
const abortController = new AbortController()
const {signal} = abortController
const videoNode = videoRef.current
videoNode.addEventListener(
'ended',
() => {
flushOnLoop()
videoNode.currentTime = 0
const maybePromise = videoNode.play() as Promise<void> | undefined
if (maybePromise) {
maybePromise.catch(() => {})
}
},
{signal},
)
return () => {
abortController.abort()
}
}, [
playlist,
setError,
setHasSubtitleTrack,
videoRef,
handleFragChange,
flushOnLoop,
Hls,
])
}, [videoRef, flushOnLoop, hasLowQualityFragmentAtStart])
return hlsRef
return {
hlsRef,
loop: !hasLowQualityFragmentAtStart,
}
}
@@ -27,6 +27,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_WEB_MOBILE_IOS, IS_WEB_TOUCH_DEVICE} from '#/env'
import {GifPresentationControls} from '../../GifPresentationControls'
import {TimeIndicator} from '../TimeIndicator'
import {ControlButton} from './ControlButton'
import {Scrubber} from './Scrubber'
@@ -44,6 +45,8 @@ export function Controls({
fullscreenRef,
hlsLoading,
hasSubtitleTrack,
isGif,
altText,
}: {
videoRef: React.RefObject<HTMLVideoElement | null>
hlsRef: React.RefObject<Hls | undefined | null>
@@ -55,6 +58,8 @@ export function Controls({
fullscreenRef: React.RefObject<HTMLDivElement | null>
hlsLoading: boolean
hasSubtitleTrack: boolean
isGif: boolean
altText?: string
}) {
const {
play,
@@ -125,13 +130,14 @@ export function Controls({
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
useEffect(() => {
if (active) {
if (onScreen) {
// GIFs play immediately, videos wait until onScreen
if (onScreen || isGif) {
if (!autoplayDisabled) play()
} else {
pause()
}
}
}, [onScreen, pause, active, play, autoplayDisabled])
}, [onScreen, pause, active, play, autoplayDisabled, isGif])
// use minimal quality when not focused
useEffect(() => {
@@ -287,6 +293,17 @@ export function Controls({
((focused || autoplayDisabled) && !playing) ||
(interactingViaKeypress ? hasFocus : hovered)
if (isGif) {
return (
<GifPresentationControls
isPlaying={playing}
isLoading={showSpinner}
onPress={onPressPlayPause}
altText={altText}
/>
)
}
return (
<div
style={{
+32 -24
View File
@@ -6,11 +6,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a} from '#/alf'
import {atoms as a, platform} from '#/alf'
import {Button} from '#/components/Button'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifPresentationControls} from './GifPresentationControls'
import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
@@ -101,33 +102,40 @@ function InnerWrapper({embed}: Props) {
{
backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
// the play button won't show up on the first render on android 🥴😮‍💨
display: showOverlay ? 'flex' : 'none',
},
platform({
android: {display: showOverlay ? 'flex' : 'none'},
ios: {zIndex: showOverlay ? 1 : -1},
}),
]}
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
>
{showOverlay && (
<Button
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)}>
{showSpinner ? (
<View
style={[
a.rounded_full,
a.p_xs,
a.align_center,
a.justify_center,
]}>
<ActivityIndicator size="large" color="white" />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
)}
{showOverlay &&
(embed.presentation === 'gif' ? (
<GifPresentationControls
isPlaying={false}
isLoading={showSpinner}
onPress={() => {
ref.current?.togglePlayback()
}}
altText={embed.alt}
/>
) : (
<Button
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)}>
{showSpinner ? (
<View style={[a.align_center, a.justify_center]}>
<ActivityIndicator size="large" color="white" />
</View>
) : (
<PlayButtonIcon />
)}
</Button>
))}
</ImageBackground>
</>
)
@@ -26,15 +26,25 @@ import {IS_WEB_FIREFOX} from '#/env'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
const noop = () => {}
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {active, setActive, sendPosition, currentActiveView} =
useActiveVideoWeb()
const {
active: activeFromContext,
setActive,
sendPosition,
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined)
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -43,15 +53,18 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition(
entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
)
}
},
{threshold: 0.5},
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition, isFullscreen])
}, [sendPosition, isFullscreen, isGif])
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -107,7 +120,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
return (
<View style={[a.pt_xs]}>
<ViewportObserver
sendPosition={sendPosition}
sendPosition={isGif ? noop : sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ConstrainedImage
fullBleed
+20 -15
View File
@@ -9,6 +9,7 @@ import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorSearch} from '#/state/queries/actor-search'
@@ -207,6 +208,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
if (
hasSearchText &&
!isFetchingSearchResults &&
!_items.length &&
!isSearchResultsError
) {
_items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
return _items
}, [
_,
@@ -219,17 +229,9 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
currentAccount?.did,
hasSearchText,
resultsKey,
isSearchResultsError,
])
if (
searchText &&
!isFetchingSearchResults &&
!items.length &&
!isSearchResultsError
) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item, index}: {item: Item; index: number}) => {
switch (item.type) {
@@ -262,7 +264,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
const selectedInterestRef = useRef(selectedInterest)
selectedInterestRef.current = selectedInterest
const onViewableItemsChanged = useRef(
const onViewableItemsChanged = useNonReactiveCallback(
({viewableItems}: {viewableItems: ViewToken[]}) => {
for (const viewableItem of viewableItems) {
const item = viewableItem.item as Item
@@ -274,7 +276,7 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
)
ax.metric('suggestedUser:seen', {
logContext: 'ProgressGuide',
recId: undefined,
recId: hasSearchText ? undefined : suggestions?.recId,
position: position !== -1 ? position : 0,
suggestedDid: item.profile.did,
category: selectedInterestRef.current,
@@ -283,10 +285,13 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) {
}
}
},
).current
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
}).current
)
const viewabilityConfig = useMemo(
() => ({
itemVisiblePercentThreshold: 50,
}),
[],
)
const onSelectTab = useCallback(
(interest: string) => {
+24 -7
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
@@ -27,6 +27,16 @@ export type RichTextProps = TextStyleProp &
interactiveStyle?: StyleProp<TextStyle>
emojiMultiplier?: number
shouldProxyLinks?: boolean
/**
* DANGEROUS: Disable facet lexicon validation
*
* `detectFacetsWithoutResolution()` generates technically invalid facets,
* with a handle in place of the DID. This means that RichText that uses it
* won't be able to render links.
*
* Use with care - only use if you're rendering facets you're generating yourself.
*/
disableMentionFacetValidation?: true
}
export function RichText({
@@ -44,12 +54,17 @@ export function RichText({
onLayout,
onTextLayout,
shouldProxyLinks,
disableMentionFacetValidation,
}: RichTextProps) {
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
)
const richText = useMemo(() => {
if (value instanceof RichTextAPI) {
return value
} else {
const rt = new RichTextAPI({text: value})
rt.detectFacetsWithoutResolution()
return rt
}
}, [value])
const plainStyles = [a.leading_snug, style]
const interactiveStyles = [plainStyles, interactiveStyle]
@@ -98,9 +113,11 @@ export function RichText({
const link = segment.link
const mention = segment.mention
const tag = segment.tag
if (
mention &&
AppBskyRichtextFacet.validateMention(mention).success &&
(disableMentionFacetValidation ||
AppBskyRichtextFacet.validateMention(mention).success) &&
!disableLinks
) {
els.push(
@@ -0,0 +1,154 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_E2E, IS_NATIVE, IS_WEB} from '#/env'
import {createIsEnabledCheck, isExistingUserAsOf} from './utils'
export const enabled = createIsEnabledCheck(props => {
return (
!IS_E2E &&
IS_NATIVE &&
isExistingUserAsOf(
'2026-02-05T00:00:00.000Z',
props.currentProfile.createdAt,
)
)
})
export function DraftsAnnouncement() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle fill={t.palette.primary_400} />
<Dialog.ScrollableInner
label={_(msg`Introducing drafts`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
{
paddingTop: IS_WEB ? 24 : 40,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<LinearGradient
colors={[t.palette.primary_100, t.palette.primary_200]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 0, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View
style={[a.flex_row, a.align_center, a.gap_xs, {marginBottom: -12}]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[
a.font_semi_bold,
{
color: t.palette.primary_800,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/drafts_announcement_nux.webp')}
style={[
a.w_full,
{
aspectRatio: 393 / 226,
},
]}
alt={_(
msg({
message: `A screenshot of the post composer with a new button next to the post button that says "Drafts", with a rainbow firework effect. Below, the text in the composer reads "Hey, did you hear the news? Bluesky has drafts now!!!".`,
comment:
'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.',
}),
)}
/>
</View>
<View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}>
<View style={[a.gap_sm, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_bold,
a.text_center,
{
fontSize: IS_WEB ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Drafts</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
Not ready to hit post? Keep your best ideas in Drafts until the
timing is just right.
</Trans>
</Text>
</View>
{!IS_WEB && (
<Button
label={_(msg`Close`)}
size="large"
color="primary"
onPress={() => control.close()}
style={[a.w_full]}>
<ButtonText>
<Trans>Finally!</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+6 -6
View File
@@ -19,9 +19,9 @@ import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {
enabled as isLiveNowBetaDialogEnabled,
LiveNowBetaDialog,
} from '#/components/dialogs/nuxs/LiveNowBetaDialog'
DraftsAnnouncement,
enabled as isDraftsAnnouncementEnabled,
} from '#/components/dialogs/nuxs/DraftsAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
import {useAnalytics} from '#/analytics'
@@ -37,8 +37,8 @@ const queuedNuxs: {
enabled?: (props: EnabledCheckProps) => boolean
}[] = [
{
id: Nux.LiveNowBetaDialog,
enabled: isLiveNowBetaDialogEnabled,
id: Nux.DraftsAnnouncement,
enabled: isDraftsAnnouncementEnabled,
},
]
@@ -186,7 +186,7 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.LiveNowBetaDialog && <LiveNowBetaDialog />}
{activeNux === Nux.DraftsAnnouncement && <DraftsAnnouncement />}
</Context.Provider>
)
}
+13 -6
View File
@@ -1,5 +1,10 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useMemo, useState} from 'react'
import {
LayoutAnimation,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {type ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -64,16 +69,17 @@ function ContentHiderActive({
style,
childContainerStyle,
children,
}: React.PropsWithChildren<{
}: {
testID?: string
modui: ModerationUI
style?: StyleProp<ViewStyle>
childContainerStyle?: StyleProp<ViewStyle>
}>) {
children?: React.ReactNode
}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const [override, setOverride] = React.useState(false)
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
const {labelDefs} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings()
@@ -81,7 +87,7 @@ function ContentHiderActive({
const blur = modui?.blurs[0]
const desc = useModerationCauseDescription(blur)
const labelName = React.useMemo(() => {
const labelName = useMemo(() => {
if (!modui?.blurs || !blur) {
return undefined
}
@@ -150,6 +156,7 @@ function ContentHiderActive({
e.preventDefault()
e.stopPropagation()
if (!modui.noOverride) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setOverride(v => !v)
} else {
control.open()
+10 -8
View File
@@ -1,5 +1,6 @@
import React, {type ComponentProps} from 'react'
import {useCallback, useState} from 'react'
import {
LayoutAnimation,
Pressable,
type StyleProp,
StyleSheet,
@@ -17,7 +18,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {addStyle} from '#/lib/styles'
import {precacheProfile} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
import {Link} from '#/view/com/util/Link'
import {atoms as a, useTheme} from '#/alf'
@@ -27,7 +28,7 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
interface Props extends ComponentProps<typeof Link> {
interface Props extends React.ComponentProps<typeof Link> {
disabled: boolean
iconSize: number
iconStyles: StyleProp<ViewStyle>
@@ -54,15 +55,15 @@ export function PostHider({
const queryClient = useQueryClient()
const t = useTheme()
const {_} = useLingui()
const [override, setOverride] = React.useState(false)
const [override, setOverride] = useState(false)
const control = useModerationDetailsDialogControl()
const blur =
modui.blurs[0] ||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, profile)
const onBeforePress = useCallback(() => {
unstableCacheProfileView(queryClient, profile)
}, [queryClient, profile])
if (!blur || (disabled && !modui.noOverride)) {
@@ -83,14 +84,15 @@ export function PostHider({
<Pressable
onPress={() => {
if (!modui.noOverride) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setOverride(v => !v)
}
}}
accessibilityRole="button"
accessibilityHint={
accessibilityLabel={
override ? _(msg`Hides the content`) : _(msg`Shows the content`)
}
accessibilityLabel=""
accessibilityHint=""
style={[
a.flex_row,
a.align_center,
@@ -28,7 +28,7 @@ export function VerifierDialog({
verificationState: FullVerificationState
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Inner
control={control}
@@ -123,7 +123,6 @@ function Inner({
}),
)}
size="small"
variant="solid"
color="primary"
style={[a.justify_center]}
onPress={() => {
@@ -138,7 +137,6 @@ function Inner({
<Button
label={_(msg`Close dialog`)}
size="small"
variant="solid"
color="secondary"
onPress={() => {
control.close()
+22 -3
View File
@@ -1,4 +1,5 @@
import {createContext, useContext, useMemo} from 'react'
import {hasMutedWord} from '@atproto/api'
import {QueryClient, useQuery} from '@tanstack/react-query'
import {useOnAppStateChange} from '#/lib/appState'
@@ -8,7 +9,8 @@ import {
isBskyCustomFeedUrl,
makeRecordUri,
} from '#/lib/strings/url-helpers'
import {LIVE_EVENTS_URL} from '#/env'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {IS_DEV, LIVE_EVENTS_URL} from '#/env'
import {useLiveEventPreferences} from '#/features/liveEvents/preferences'
import {type LiveEventsWorkerResponse} from '#/features/liveEvents/types'
import {useDevMode} from '#/storage/hooks/dev-mode'
@@ -36,6 +38,12 @@ const Context = createContext<LiveEventsWorkerResponse>(DEFAULT_LIVE_EVENTS)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [isDevMode] = useDevMode()
const isBskyTeam = useIsBskyTeam()
const {data: preferences} = usePreferencesQuery()
const mutedWords = useMemo(
() => preferences?.moderationPrefs?.mutedWords ?? [],
[preferences?.moderationPrefs?.mutedWords],
)
const {data, refetch} = useQuery(
{
// keep this, prefectching handles initial load
@@ -50,13 +58,24 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
useOnAppStateChange(state => {
if (state === 'active') refetch()
if (state === 'active') void refetch()
})
const ctx = useMemo(() => {
if (!data) return DEFAULT_LIVE_EVENTS
const skipMuteFilter = isBskyTeam || IS_DEV
const feeds = data.feeds.filter(f => {
if (f.preview && !isBskyTeam) return false
if (!skipMuteFilter && mutedWords.length > 0) {
const text = [
f.title,
f.layouts?.wide?.title,
f.layouts?.compact?.title,
]
.filter(Boolean)
.join(' ')
if (hasMutedWord({mutedWords, text})) return false
}
return true
})
return {
@@ -64,7 +83,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// only one at a time for now, unless bsky team and dev mode
feeds: isBskyTeam && isDevMode ? feeds : feeds.slice(0, 1),
}
}, [data, isBskyTeam, isDevMode])
}, [data, isBskyTeam, isDevMode, mutedWords])
return <Context.Provider value={ctx}>{children}</Context.Provider>
}
@@ -0,0 +1,87 @@
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
jest.mock('@bsky.app/react-native-mmkv', () => ({
MMKV: class MMKVMock {
_store = new Map<string, string>()
getString(key: string) {
return this._store.get(key)
}
set(key: string, value: string) {
this._store.set(key, value)
}
delete(key: string) {
this._store.delete(key)
}
clearAll() {
this._store.clear()
}
},
}))
import {createPersistedQueryStorage} from '../persisted-query-storage'
describe('createPersistedQueryStorage', () => {
it('should create isolated storage instances', async () => {
const storage1 = createPersistedQueryStorage('store1')
const storage2 = createPersistedQueryStorage('store2')
await storage1.setItem('key', 'value1')
await storage2.setItem('key', 'value2')
expect(await storage1.getItem('key')).toBe('value1')
expect(await storage2.getItem('key')).toBe('value2')
})
describe('storage operations', () => {
let storage: ReturnType<typeof createPersistedQueryStorage>
beforeEach(() => {
storage = createPersistedQueryStorage('test_store')
})
it('should return null for non-existent keys', async () => {
const result = await storage.getItem('non-existent-key')
expect(result).toBeNull()
})
it('should store and retrieve a value', async () => {
const testValue = JSON.stringify({data: 'test'})
await storage.setItem('test-key', testValue)
const result = await storage.getItem('test-key')
expect(result).toBe(testValue)
})
it('should remove a value', async () => {
const testValue = JSON.stringify({data: 'test'})
await storage.setItem('test-key', testValue)
await storage.removeItem('test-key')
const result = await storage.getItem('test-key')
expect(result).toBeNull()
})
it('should handle complex JSON data', async () => {
const complexData = JSON.stringify({
queries: [
{key: 'query1', data: {nested: {value: 123}}},
{key: 'query2', data: {array: [1, 2, 3]}},
],
timestamp: Date.now(),
})
await storage.setItem('complex-key', complexData)
const result = await storage.getItem('complex-key')
expect(result).toBe(complexData)
expect(JSON.parse(result!)).toEqual(JSON.parse(complexData))
})
it('should overwrite existing values', async () => {
await storage.setItem('test-key', 'value1')
await storage.setItem('test-key', 'value2')
const result = await storage.getItem('test-key')
expect(result).toBe('value2')
})
})
})
+2
View File
@@ -354,6 +354,8 @@ async function resolveMedia(
alt: videoDraft.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio,
presentation:
videoDraft.video.mimeType === 'image/gif' ? 'gif' : 'default',
}
}
if (embedDraft.media?.type === 'gif') {
+18
View File
@@ -0,0 +1,18 @@
import * as Device from 'expo-device'
import * as env from '#/env'
export const FALLBACK_ANDROID = 'Android'
export const FALLBACK_IOS = 'iOS'
export const FALLBACK_WEB = 'Web'
export function getDeviceName(): string {
const deviceName = Device.deviceName
if (env.IS_ANDROID) {
return deviceName || FALLBACK_ANDROID
} else if (env.IS_IOS) {
return deviceName || FALLBACK_IOS
} else {
return FALLBACK_WEB // could append browser info here
}
}
@@ -1,107 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import {
KeyboardProvider,
useKeyboardController,
} from 'react-native-keyboard-controller'
import {useFocusEffect} from '@react-navigation/native'
const KeyboardControllerRefCountContext = createContext<{
incrementRefCount: () => void
decrementRefCount: () => void
}>({
incrementRefCount: () => {},
decrementRefCount: () => {},
})
KeyboardControllerRefCountContext.displayName =
'KeyboardControllerRefCountContext'
export function KeyboardControllerProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<KeyboardProvider enabled={false} preload={false}>
<KeyboardControllerProviderInner>
{children}
</KeyboardControllerProviderInner>
</KeyboardProvider>
)
}
function KeyboardControllerProviderInner({
children,
}: {
children: React.ReactNode
}) {
const {setEnabled} = useKeyboardController()
const refCount = useRef(0)
const value = useMemo(
() => ({
incrementRefCount: () => {
refCount.current++
setEnabled(refCount.current > 0)
},
decrementRefCount: () => {
refCount.current--
setEnabled(refCount.current > 0)
if (__DEV__ && refCount.current < 0) {
console.error('KeyboardController ref count < 0')
}
},
}),
[setEnabled],
)
return (
<KeyboardControllerRefCountContext.Provider value={value}>
{children}
</KeyboardControllerRefCountContext.Provider>
)
}
export function useEnableKeyboardController(shouldEnable: boolean) {
const {incrementRefCount, decrementRefCount} = useContext(
KeyboardControllerRefCountContext,
)
useEffect(() => {
if (!shouldEnable) {
return
}
incrementRefCount()
return () => {
decrementRefCount()
}
}, [shouldEnable, incrementRefCount, decrementRefCount])
}
/**
* Like `useEnableKeyboardController`, but using `useFocusEffect`
*/
export function useEnableKeyboardControllerScreen(shouldEnable: boolean) {
const {incrementRefCount, decrementRefCount} = useContext(
KeyboardControllerRefCountContext,
)
useFocusEffect(
useCallback(() => {
if (!shouldEnable) {
return
}
incrementRefCount()
return () => {
decrementRefCount()
}
}, [shouldEnable, incrementRefCount, decrementRefCount]),
)
}
+41
View File
@@ -0,0 +1,41 @@
import {create as createArchiveDB} from '#/storage/archive/db'
/**
* Interface for async storage compatible with @tanstack/query-async-storage-persister
*/
export interface PersistedQueryStorage {
getItem: (key: string) => Promise<string | null>
setItem: (key: string, value: string) => Promise<void>
removeItem: (key: string) => Promise<void>
}
function createId(id: string) {
return `react-query-cache-${id}`
}
/**
* Creates an MMKV-based storage adapter for persisting react-query cache on native platforms.
* Each storage instance uses a separate MMKV store identified by the provided id.
* MMKV provides synchronous access but we wrap it in Promises for API compatibility.
*
* @param id - Unique identifier for this storage instance (used as MMKV store id)
*/
export function createPersistedQueryStorage(id: string): PersistedQueryStorage {
const store = createArchiveDB({id: createId(id)})
return {
getItem: async (key: string): Promise<string | null> => {
return (await store.get(key)) ?? null
},
setItem: async (key: string, value: string): Promise<void> => {
await store.set(key, value)
},
removeItem: async (key: string): Promise<void> => {
await store.delete(key)
},
}
}
export async function clearPersistedQueryStorage(id: string) {
const store = createArchiveDB({id: createId(id)})
await store.clear()
}
+11 -9
View File
@@ -1,27 +1,26 @@
import {useEffect, useRef, useState} from 'react'
import {AppState, type AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
import {
type PersistQueryClientOptions,
PersistQueryClientProvider,
type PersistQueryClientProviderProps,
} from '@tanstack/react-query-persist-client'
import type React from 'react'
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
import {PERSISTED_QUERY_ROOT} from '#/state/queries'
import * as env from '#/env'
import {IS_NATIVE, IS_WEB} from '#/env'
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
__TANSTACK_QUERY_CLIENT__: import('@tanstack/query-core').QueryClient
}
}
// any query keys in this array will be persisted to AsyncStorage
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
async function checkIsOnline(): Promise<boolean> {
try {
const controller = new AbortController()
@@ -138,7 +137,8 @@ const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehyd
{
shouldDehydrateMutation: (_: any) => false,
shouldDehydrateQuery: query => {
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
const root = String(query.queryKey[0])
return root === PERSISTED_QUERY_ROOT
},
}
@@ -177,14 +177,16 @@ function QueryProviderInner({
// Do not move the query client creation outside of this component.
const [queryClient, _setQueryClient] = useState(() => createQueryClient())
const [persistOptions, _setPersistOptions] = useState(() => {
const storage = createPersistedQueryStorage(currentDid ?? 'logged-out')
const asyncPersister = createAsyncStoragePersister({
storage: AsyncStorage,
storage,
key: 'queryClient-' + (currentDid ?? 'logged-out'),
})
return {
persister: asyncPersister,
dehydrateOptions,
}
buster: env.APP_VERSION,
} satisfies Omit<PersistQueryClientOptions, 'queryClient'>
})
useEffect(() => {
if (IS_WEB) {
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
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
-3
View File
@@ -4,7 +4,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePreventRemove} from '@react-navigation/native'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
@@ -37,8 +36,6 @@ export function FindContactsFlowScreen({navigation}: Props) {
})
})
useEnableKeyboardControllerScreen(true)
const setMinimalShellMode = useSetMinimalShellMode()
const effect = useCallback(() => {
setMinimalShellMode(true)
-3
View File
@@ -15,7 +15,6 @@ import {
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {
type CommonNavigatorParams,
@@ -68,8 +67,6 @@ export function MessagesConversationScreenInner({route}: Props) {
const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId()
useEnableKeyboardControllerScreen(true)
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
+4 -2
View File
@@ -103,10 +103,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
label={_(
msg({context: 'allow messages from', message: `No one`}),
)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>No one</Trans>
<Trans context="allow messages from">No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
@@ -96,10 +96,13 @@ export function StepFinished() {
const {selectedInterests} = interestsStepResults
await Promise.all([
bulkWriteFollows(agent, [
BSKY_APP_ACCOUNT_DID,
...(listItems?.map(i => i.subject.did) ?? []),
]),
bulkWriteFollows(
agent,
[BSKY_APP_ACCOUNT_DID, ...(listItems?.map(i => i.subject.did) ?? [])],
starterPack
? {uri: starterPack.uri, cid: starterPack.cid}
: undefined,
),
(async () => {
// Interests need to get saved first, then we can write the feeds to prefs
await agent.setInterestsPref({tags: selectedInterests})
@@ -97,6 +97,17 @@ export function StepSuggestedAccounts() {
tab: selectedInterest ?? 'all',
numAccounts: followableDids.length,
})
for (let i = 0; i < followableDids.length; i++) {
const did = followableDids[i]
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'FollowAll',
recId: suggestedUsers?.recId,
position: i,
suggestedDid: did,
category: selectedInterest,
})
}
},
mutationFn: async () => {
for (const did of followableDids) {
@@ -135,14 +146,14 @@ export function StepSuggestedAccounts() {
seenProfilesRef.current.add(did)
ax.metric('suggestedUser:seen', {
logContext: 'Onboarding',
recId: undefined,
recId: suggestedUsers?.recId,
position,
suggestedDid: did,
category: selectedInterest,
})
}
},
[ax, selectedInterest],
[ax, selectedInterest, suggestedUsers?.recId],
)
return (
@@ -220,6 +231,7 @@ export function StepSuggestedAccounts() {
position={index}
category={selectedInterest}
onSeen={onProfileSeen}
recId={suggestedUsers.recId}
/>
))}
</View>
@@ -234,7 +246,7 @@ export function StepSuggestedAccounts() {
color="secondary"
size="large"
label={_(msg`Retry`)}
onPress={() => refetch()}>
onPress={() => void refetch()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -329,12 +341,14 @@ function SuggestedProfileCard({
position,
category,
onSeen,
recId,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
position: number
category: string | null
onSeen: (did: string, position: number) => void
recId?: number | string
}) {
const t = useTheme()
const ax = useAnalytics()
@@ -401,7 +415,7 @@ function SuggestedProfileCard({
ax.metric('suggestedUser:follow', {
logContext: 'Onboarding',
location: 'Card',
recId: undefined,
recId,
position,
suggestedDid: profile.did,
category,
@@ -72,7 +72,10 @@ export function StarterPackCard({
let followUris: Map<string, string>
try {
followUris = await bulkWriteFollows(agent, dids)
followUris = await bulkWriteFollows(agent, dids, {
uri: view.uri,
cid: view.cid,
})
} catch (e) {
setIsProcessing(false)
Toast.show(_(msg`An error occurred while trying to follow all`), {
-3
View File
@@ -2,7 +2,6 @@ import {useMemo, useReducer} from 'react'
import {View} from 'react-native'
import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useLanguagePrefs} from '#/state/preferences'
import {
Layout,
@@ -60,8 +59,6 @@ export function Onboarding() {
)
const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
useEnableKeyboardControllerScreen(true)
return (
<Portal>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
+7 -1
View File
@@ -4,13 +4,18 @@ import {
type AppBskyGraphGetFollows,
type BskyAgent,
type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until'
export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
export async function bulkWriteFollows(
agent: BskyAgent,
dids: string[],
via?: ComAtprotoRepoStrongRef.Main,
) {
const session = agent.session
if (!session) {
@@ -22,6 +27,7 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
$type: 'app.bsky.graph.follow',
subject: did,
createdAt: new Date().toISOString(),
via,
}
})
@@ -303,7 +303,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<>
<View style={[a.mb_2xs]}>
<RichText
enableTags
value={richText}
@@ -318,7 +318,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
onPress={onPressShowMore}
/>
)}
</>
</View>
) : undefined}
{post.embed && (
<View style={[a.pb_xs]}>
@@ -343,7 +343,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<>
<View style={[a.mb_2xs]}>
<RichText
enableTags
value={richText}
@@ -358,7 +358,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
onPress={onPressShowMore}
/>
)}
</>
</View>
) : null}
{post.embed && (
<View style={[a.pb_xs]}>
@@ -43,7 +43,7 @@ import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
import {ProfileHeaderShell} from './Shell'
import {AnimatedProfileHeaderSuggestedFollows} from './SuggestedFollows'
import {ProfileHeaderSuggestedFollows} from './SuggestedFollows'
interface Props {
profile: AppBskyActorDefs.ProfileViewDetailed
@@ -193,7 +193,7 @@ let ProfileHeaderStandard = ({
/>
</ProfileHeaderShell>
<AnimatedProfileHeaderSuggestedFollows
<ProfileHeaderSuggestedFollows
isExpanded={showSuggestedFollows}
actorDid={profile.did}
/>
@@ -317,7 +317,10 @@ export function HeaderStandardButtons({
testID="profileHeaderEditProfileButton"
size="small"
color="secondary"
onPress={editProfileControl.open}
onPress={() => {
playHaptic('Light')
editProfileControl.open()
}}
label={_(msg`Edit profile`)}>
<ButtonText>
<Trans>Edit Profile</Trans>
+86 -189
View File
@@ -1,5 +1,4 @@
import React from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {AccordionAnimation} from '#/lib/custom-animations/AccordionAnimation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -10,198 +9,17 @@ import {
import {useBreakpoints} from '#/alf'
import {ProfileGrid} from '#/components/FeedInterstitials'
import {IS_ANDROID} from '#/env'
import type * as bsky from '#/types/bsky'
const DISMISS_ANIMATION_DURATION = 200
export function ProfileHeaderSuggestedFollows({actorDid}: {actorDid: string}) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 12
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
did: actorDid,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((did: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(did))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(did))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(did)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: AppBskyActorDefs.ProfileView[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== actorDid) {
seen.add(profile.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, actorDid])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return (
<ProfileGrid
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profileHeader"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
/>
)
}
export function AnimatedProfileHeaderSuggestedFollows({
export function ProfileHeaderSuggestedFollows({
isExpanded,
actorDid,
}: {
isExpanded: boolean
actorDid: string
}) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 12
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
did: actorDid,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
new Set(),
)
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
new Set(),
)
const onDismiss = React.useCallback((did: string) => {
// Start the fade animation
setDismissingDids(prev => new Set(prev).add(did))
// After animation completes, actually remove from list
setTimeout(() => {
setDismissedDids(prev => new Set(prev).add(did))
setDismissingDids(prev => {
const next = new Set(prev)
next.delete(did)
return next
})
}, DISMISS_ANIMATION_DURATION)
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = React.useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: AppBskyActorDefs.ProfileView[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push(profile)
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.did) && profile.did !== actorDid) {
seen.add(profile.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, actorDid])
const filteredProfiles = React.useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
React.useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
const {allProfiles, filteredProfiles, onDismiss, isLoading, error} =
useProfileHeaderSuggestions(actorDid)
if (!allProfiles.length && !isLoading) return null
@@ -218,13 +36,92 @@ export function AnimatedProfileHeaderSuggestedFollows({
isSuggestionsLoading={isLoading}
profiles={filteredProfiles}
totalProfileCount={allProfiles.length}
recId={data?.recId}
error={error}
viewContext="profileHeader"
onDismiss={onDismiss}
dismissingDids={dismissingDids}
isVisible={isExpanded}
/>
</AccordionAnimation>
)
}
function useProfileHeaderSuggestions(actorDid: string) {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const maxLength = gtMobile ? 4 : 12
const {isLoading, data, error} = useSuggestedFollowsByActorQuery({
did: actorDid,
})
const {
data: moreSuggestions,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useSuggestedFollowsQuery({limit: 25})
const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
const onDismiss = useCallback((did: string) => {
setDismissedDids(prev => new Set(prev).add(did))
}, [])
// Combine profiles from the actor-specific query with fallback suggestions
const allProfiles = useMemo(() => {
const actorProfiles = data?.suggestions ?? []
const fallbackProfiles =
moreSuggestions?.pages.flatMap(page =>
page.actors.map(actor => ({actor, recId: page.recId})),
) ?? []
// Dedupe by did, preferring actor-specific profiles
const seen = new Set<string>()
const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
for (const profile of actorProfiles) {
if (!seen.has(profile.did)) {
seen.add(profile.did)
combined.push({actor: profile, recId: data?.recId})
}
}
for (const profile of fallbackProfiles) {
if (!seen.has(profile.actor.did) && profile.actor.did !== actorDid) {
seen.add(profile.actor.did)
combined.push(profile)
}
}
return combined
}, [data?.suggestions, moreSuggestions?.pages, actorDid, data?.recId])
const filteredProfiles = useMemo(() => {
return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
}, [allProfiles, dismissedDids])
// Fetch more when running low
useEffect(() => {
if (
moderationOpts &&
filteredProfiles.length < maxLength &&
hasNextPage &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [
filteredProfiles.length,
maxLength,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
moderationOpts,
])
return {
allProfiles,
filteredProfiles,
onDismiss,
isLoading,
error,
}
}
@@ -45,6 +45,7 @@ export function useSuggestedUsers({
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
recId: undefined,
}
: undefined,
isLoading: searched.isLoading,

Some files were not shown because too many files have changed in this diff Show More