Merge remote-tracking branch 'origin/main' into fix-layout-animation-reduced-motion

* origin/main: (23 commits)
  Add video playback analytics events (#11629)
  Preserve thread numbering in placeholders (#11616)
  Improve rotation text clipping (#11487)
  Fix Starter Pack share link on iOS (#11626)
  Truncate handles in collapsed profile headers (#11577)
  APP-3014: prevent scheduler delegate use-after-free (#11632)
  Fix localization for Following and Discover feed names (#11572)
  Pull latest lexicons (video alt text fix) (#11631)
  Nightly source-language update
  Vendor updated lexicons, WILL FAIL UPDATE (#11630)
  Fix post view attribution for feed replies (#11622)
  Fix bottom sheet content width on Android tablets (native-owned canvas sizing) (#11396)
  Bump actions/setup-java from 5.7.0 to 6.0.0 (#11619)
  Bump the actions group with 2 updates (#11618)
  Stabilize native composer focus (#11624)
  APP-2983: Fix RTL post alignment on native (#11600)
  Nightly source-language update
  Revert "APP-2974: Avoid composer layout animations"
  APP-2974: Avoid composer layout animations
  Update `useAutoPagination` max attempts value (#11623)
  ...
This commit is contained in:
Eric Bailey
2026-09-02 11:29:06 -05:00
75 changed files with 2141 additions and 1243 deletions
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
with:
expo-token: ${{ secrets.EXPO_TOKEN }}
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
@@ -195,7 +195,7 @@ jobs:
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
# like the build job so the toolchain is deterministic.
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: "temurin"
java-version: "17"
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
fetch-depth: 1
- name: 🤖 Claude review
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
env:
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
with:
+2 -2
View File
@@ -51,7 +51,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -176,7 +176,7 @@ jobs:
expo-token: ${{ secrets.EXPO_TOKEN }}
- name: ☕️ Set up Java 17
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
with:
distribution: temurin
java-version: "17"
@@ -35,7 +35,7 @@ jobs:
commit_message: Nightly source-language update
file_pattern: ./src/locale/locales/en/messages.po
- name: 🚀 Push source lang to Crowdin
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
upload_sources: true
upload_sources_args: "-b main"
+2 -2
View File
@@ -426,7 +426,7 @@
},
"app.bsky.embed.video": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.embed.video",
"cid": "bafyreiaqos23yv3t4ptrxily6s6qea5fcxfjzjlm42zq46xweby2mkgr4m"
"cid": "bafyreihoxb7lvczityqcv2s5od3rllmkee7tn3m4qg6wn34kd6m45p6v24"
},
"app.bsky.feed.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.defs",
@@ -538,7 +538,7 @@
},
"app.bsky.graph.defs": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.defs",
"cid": "bafyreifcipomli7yggtl46xufgxlnrw7se6xmsdxmzgfcz2tiu76ljatxm"
"cid": "bafyreief2f7zpllyicjugbn7faohmnzwujeiytfzj76uckxmrqmtdvechy"
},
"app.bsky.graph.follow": {
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
+2 -6
View File
@@ -9,9 +9,7 @@
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"description": "Alt text description of the video, for accessibility.",
"maxGraphemes": 1000
"description": "Alt text description of the video, for accessibility."
},
"video": {
"type": "blob",
@@ -51,9 +49,7 @@
],
"properties": {
"alt": {
"type": "string",
"maxLength": 10000,
"maxGraphemes": 1000
"type": "string"
},
"cid": {
"type": "string",
+27 -27
View File
@@ -169,6 +169,28 @@
}
}
},
"knownLikers": {
"type": "object",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"items": {
"ref": "app.bsky.actor.defs#profileViewBasic",
"type": "ref"
},
"maxLength": 5,
"minLength": 0
}
},
"description": "The post's likers whom you also follow"
},
"requestLess": {
"type": "token",
"description": "Request that less content like the given feed item be shown in the feed"
@@ -194,6 +216,11 @@
"bookmarked": {
"type": "boolean"
},
"knownLikers": {
"ref": "#knownLikers",
"type": "ref",
"description": "This property is present only in selected cases, as an optimization."
},
"threadMuted": {
"type": "boolean"
},
@@ -202,37 +229,10 @@
},
"embeddingDisabled": {
"type": "boolean"
},
"knownLikers": {
"description": "This property is present only in selected cases, as an optimization.",
"type": "ref",
"ref": "#knownLikers"
}
},
"description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests."
},
"knownLikers": {
"type": "object",
"description": "The post's likers whom you also follow",
"required": [
"count",
"actors"
],
"properties": {
"count": {
"type": "integer"
},
"actors": {
"type": "array",
"minLength": 0,
"maxLength": 5,
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#profileViewBasic"
}
}
}
},
"feedViewPost": {
"type": "object",
"required": [
+10
View File
@@ -100,6 +100,11 @@
"subject": {
"ref": "app.bsky.actor.defs#profileView",
"type": "ref"
},
"subjectOptedOut": {
"type": "boolean",
"const": true,
"description": "Set to true when the subject has opted out of appearing in the reference list. Only set when the viewer owns the list."
}
}
},
@@ -228,6 +233,11 @@
"blocked": {
"type": "string",
"format": "at-uri"
},
"referenceListOptOut": {
"type": "string",
"format": "at-uri",
"description": "The authenticated viewer's app.bsky.graph.referencelistoptout record URI for this reference list. Only set for reference lists. A client can delete this record to undo the opt-out."
}
}
},
+24 -1
View File
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
- Preserves status/nav bar appearance from host activity
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
- Forwards touch events to React Native event system
- Updates shadow node size to match window dimensions
- Reports its measured width to `BottomSheetView` so the content canvas can follow it
- Also carries the legacy `UIManagerModule.updateNodeSize()` shadow node sizing, which only runs on the old architecture
- Based on React Native's ReactModalHostView pattern
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
@@ -74,6 +75,24 @@ Both platforms detect content height changes natively without JS bridge round-tr
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
### Content Canvas Sizing
The "canvas" is the size the sheet content is laid out on by Yoga. **On Android the native side owns it**; on iOS it is still sized from JS.
- **Android**: JS renders unsized `flex: 1` content and `BottomSheetView` pushes the canvas size into the Fabric shadow tree through `ExpoView`'s `setViewSize` state channel (`shadowNodeProxy.setViewSize()`). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.
- **iOS**: `BottomSheetNativeComponent` sets `height: screenHeight - insets.top` and `width: '100%'` on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).
How the Android path works:
- The JS style on the native view **must not set `width` or `height`** on Android. `ExpoViewComponentDescriptor::adopt()` only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.
- The two axes come from different places, and the distinction is load-bearing:
- **Width** is authoritatively the dialog container's measured width, reported through `DialogRootViewGroup`'s size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from `min(window width, material_bottom_sheet_max_width)` on the first `onLayout` so content has something to lay out in before the dialog exists.
- **Height** is always computed natively as `screenHeight - statusBarHeight` (matching the behavior's `expandedOffset`) - the whole expanded frame, **never** the dialog's measured height. The canvas has to be room for the content to grow *into*, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: `BottomSheetBehavior` measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra `ScrollView` padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, `OnLayoutChangeListener` never fires, and the sheet stops responding to its content.
- Seeding runs once per open cycle - re-seeding would fight the width the dialog reported and the two would push each other back and forth.
- Because the content measures 0x0 until that first state commit lands, `present()` bails out early when the content height is still zero. The commit resizes the native view, which re-fires `onLayout`, which re-enters `present()` - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.
- Rotation is handled by the container push: the RN activity handles configuration changes itself, so the view is never recreated. `screenHeight` is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred `updateLayout()` to reposition the sheet).
- On the **old architecture** there is no state channel (`stateWrapper` is null, so `setViewSize` no-ops) and Android falls back to `DialogRootViewGroup`'s legacy `UIManagerModule.updateNodeSize()` path. The `present()` gate is skipped there for the same reason - nothing would ever resize the view.
## Props
```typescript
@@ -213,6 +232,10 @@ BottomSheetNativeComponent.dismissAll()
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
5. **Tablet Width**: Material caps the sheet frame at 640dp (`material_bottom_sheet_max_width`, the `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`) and centers it horizontally, so on tablets the sheet is narrower than the screen. `BottomSheetView` reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see [Content Canvas Sizing](#content-canvas-sizing).
6. **Rotation**: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating `BottomSheetView`. Screen height is therefore read per access rather than cached, and `maxHeight` is stored unclamped and clamped against the current screen at use time.
### Platform Differences
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
/**
* Fallback for Material's `material_bottom_sheet_max_width` dimen (in dp), used only
* if the resource lookup fails. 640dp is the value Material ships.
*/
private const val FALLBACK_MAX_SHEET_WIDTH_DP = 640f
class BottomSheetView(
context: Context,
appContext: AppContext,
@@ -38,26 +44,30 @@ class BottomSheetView(
private var lastObservedContentHeight: Float = 0f
private var pendingLayoutUpdate: Boolean = false
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
// Computed per read rather than cached at construction: the RN activity handles
// configuration changes itself, so a rotation resizes the display without
// recreating this view and a cached value would stay stale for the sheet's life.
private val screenHeight: Float
get() =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
val size = android.graphics.Point()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealSize(size)
size.y.toFloat()
}
private fun getNavigationBarHeight(): Int {
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
@@ -73,6 +83,11 @@ class BottomSheetView(
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Last canvas size (in dp) pushed into the shadow tree, so repeated layout
// passes don't spam state updates
private var lastPushedCanvasWidth: Float = -1f
private var lastPushedCanvasHeight: Float = -1f
var disableDrag = false
set(value) {
field = value
@@ -99,10 +114,11 @@ class BottomSheetView(
field = if (value < 0) 0f else dpToPx(value)
}
var maxHeight = this.screenHeight
// Stored unclamped (in px) because screenHeight can change under us on rotation.
// The clamp against the screen happens at use time, in getTargetHeight().
var maxHeight = Float.MAX_VALUE
set(value) {
val px = dpToPx(value)
field = if (px > this.screenHeight) this.screenHeight else px
field = dpToPx(value)
}
private var isOpen: Boolean = false
@@ -140,6 +156,38 @@ class BottomSheetView(
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
// The dialog container's measured WIDTH is the authoritative canvas width: it
// already accounts for the window's horizontal insets, Material's max-width cap on
// tablets and the current rotation. DialogRootViewGroup's own updateNodeSize() path
// is a no-op on the new architecture (getNativeModule(UIManagerModule) returns null
// under Fabric), so this state channel is what actually gets the width across there.
//
// Its measured HEIGHT is deliberately ignored - see canvasHeight.
this.dialogRootViewGroup.setOnSizeChangeListener(
object : DialogRootViewGroup.OnSizeChangeListener {
override fun onSizeChange(
width: Int,
height: Int,
) {
val density = context.resources.displayMetrics.density
pushCanvasSize(width / density, canvasHeight / density)
// onSizeChanged fires from inside a layout pass, so defer the reposition:
// updateLayout() reads child heights that aren't final yet. This is what
// makes the sheet settle back into place after a rotation. It no-ops for
// fullHeight sheets, which is correct - those are pinned to the expanded
// offset either way.
if ((isOpen || isOpening) && !isClosing) {
post {
if ((isOpen || isOpening) && !isClosing) {
updateLayout()
}
}
}
}
},
)
}
SheetManager.add(this)
}
@@ -151,9 +199,84 @@ class BottomSheetView(
r: Int,
b: Int,
) {
this.seedCanvasSize()
this.present()
}
/**
* The height, in px, of the canvas the sheet content is laid out on. This is the whole
* expanded frame (the behavior's expandedOffset is the status bar height), NOT the
* sheet's current height.
*
* That distinction is the whole ballgame. The content's height is what drives the snap
* points, so the canvas has to be room to grow *into*. Sizing the canvas from the
* dialog's own measured height is circular - BottomSheetBehavior measures the dialog
* container against the sheet, so the canvas collapses onto the content height, and from
* then on the content is pinned: extra ScrollView padding (the Android keyboard path) or
* a longer list just becomes scroll extent instead of a height change, the
* OnLayoutChangeListener never fires, and the sheet stops responding to its content.
*/
private val canvasHeight: Float
get() = screenHeight - getStatusBarHeight()
/**
* JS renders the sheet content unsized, so before the first state commit it measures
* 0x0 and present() has no content height to derive snap points from. Seed the canvas
* here to kick that off - the dialog container reports the authoritative width later,
* via its OnSizeChangeListener.
*
* Runs at most once per open cycle. It has to: each state commit re-fires onLayout, so
* re-seeding would fight the width the dialog reported and the two would push each other
* back and forth forever.
*
* stateWrapper is assigned while Fabric mounts the view, before the first layout pass,
* so setViewSize() should already reach the shadow tree from here. On the old
* architecture it is null and this no-ops, which is fine: DialogRootViewGroup's legacy
* updateNodeSize() path still sizes the content there.
*/
private fun seedCanvasSize() {
if (lastPushedCanvasWidth > 0f) return
val density = context.resources.displayMetrics.density
val widthPx =
minOf(
context.resources.displayMetrics.widthPixels
.toFloat(),
getMaxSheetWidth(),
)
this.pushCanvasSize(widthPx / density, canvasHeight / density)
}
/**
* Sets the size of this view's shadow node, which is the canvas the sheet content is
* laid out on. Deduped because both onLayout and the dialog container's size changes
* can re-report an unchanged size.
*/
private fun pushCanvasSize(
widthDp: Float,
heightDp: Float,
) {
if (widthDp <= 0f || heightDp <= 0f) return
if (widthDp == lastPushedCanvasWidth && heightDp == lastPushedCanvasHeight) return
lastPushedCanvasWidth = widthDp
lastPushedCanvasHeight = heightDp
this.shadowNodeProxy.setViewSize(widthDp.toDouble(), heightDp.toDouble())
}
/**
* Material caps the sheet frame at `material_bottom_sheet_max_width` (the
* `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`, which our dialog theme
* inherits from) and centers it horizontally, so on tablets the sheet is narrower than
* the display. Returns the cap in px.
*/
private fun getMaxSheetWidth(): Float =
try {
resources
.getDimensionPixelSize(com.google.android.material.R.dimen.material_bottom_sheet_max_width)
.toFloat()
} catch (e: android.content.res.Resources.NotFoundException) {
FALLBACK_MAX_SHEET_WIDTH_DP * context.resources.displayMetrics.density
}
private fun destroy() {
this.stopObservingContentHeight()
this.isClosing = false
@@ -178,6 +301,15 @@ class BottomSheetView(
val contentHeight = this.getContentHeight()
// The content is unsized until the canvas size we pushed lands in the shadow tree,
// so bail and let this retry itself: the state commit resizes this view, that
// re-fires onLayout, and onLayout re-enters present(). Full-height sheets don't
// need a content measurement, so they can go ahead immediately.
//
// Only gate when there is a state channel to wait on. Without one (old architecture)
// nothing would ever resize this view, and the sheet would never present.
if (stateWrapper != null && !fullHeight && contentHeight <= 0f) return
var activityWindow: Window? = null
var currentContext = context
while (currentContext != null) {
@@ -425,8 +557,10 @@ class BottomSheetView(
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
// maxHeight is stored unclamped, so clamp it against the current screen here
val effectiveMaxHeight = minOf(this.maxHeight, this.screenHeight)
return when {
contentHeight > maxHeight -> maxHeight
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
contentHeight < minHeight -> minHeight
else -> contentHeight
}
@@ -34,10 +34,6 @@ const IS_IOS15 =
Platform.OS === 'ios' &&
// semvar - can be 3 segments, so can't use Number(Platform.Version)
Number(Platform.Version.split('.').at(0)) < 16
// older android versions (15 and below) aren't naturally edge-to-edge
// and behave a little differently
const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps,
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
const {height: screenHeight} = useWindowDimensions()
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
// sigh... on older Android versions, screenHeight does not include safe area insets
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
// for the sheet content
const sheetHeight = IS_NON_E2E_ANDROID
? screenHeight + insets.bottom
: screenHeight - insets.top
return (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
position: 'absolute',
height: sheetHeight,
width: '100%',
}}
/*
* On Android the native side owns this view's size - the canvas the sheet
* content is laid out on - and pushes it into the Fabric shadow tree through
* ExpoView's `setViewSize` state channel. It knows the real sheet frame
* (window insets, Material's max-width cap on tablets, rotation), which JS
* can only guess at. `width` and `height` must stay unset there:
* `ExpoViewComponentDescriptor::adopt()` only applies the state size on an
* axis where the style leaves that dimension undefined, so a style dimension
* would silently win and clip the content again.
*
* iOS still sizes the canvas from JS. Moving it onto the same state channel
* needs on-device iteration on iOS 26 sheet geometry (large-detent and
* floating-card metrics), so it is deferred.
*/
style={
Platform.OS === 'ios'
? {
position: 'absolute',
height: screenHeight - insets.top,
width: '100%',
}
: {position: 'absolute'}
}
containerBackgroundColor={backgroundColor}>
<View
style={[
-18
View File
@@ -317,14 +317,6 @@
"count": 4
}
},
"src/components/StarterPack/ShareDialog.tsx": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
"typescript/no-explicit-any": {
"count": 1
@@ -1318,11 +1310,6 @@
"count": 1
}
},
"src/view/com/composer/drafts/DraftsButton.tsx": {
"typescript/no-misused-promises": {
"count": 1
}
},
"src/view/com/composer/drafts/state/queries.ts": {
"typescript/no-explicit-any": {
"count": 2
@@ -1533,11 +1520,6 @@
"count": 1
}
},
"src/view/screens/Home.tsx": {
"typescript/no-floating-promises": {
"count": 1
}
},
"src/view/screens/ModerationBlockedAccounts.tsx": {
"typescript/no-misused-promises": {
"count": 3
+2 -1
View File
@@ -112,7 +112,7 @@
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.3.2",
"@bsky.app/react-native-uitextview": "^2.7.0",
"@bsky.app/react-native-uitextview": "^2.7.1",
"@bsky.app/sift": "^0.3.9",
"@bsky.app/tapper": "^0.6.1",
"@bsky.app/video": "0.3.6",
@@ -159,6 +159,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"bidi-js": "^1.0.3",
"date-fns": "^4.4.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
+217 -196
View File
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
- (void)setFrame:(CGRect)frame
{
[super setFrame:frame];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -396,7 +396,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
MAP_SCROLL_VIEW_PROP(zoomScale);
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
+ // When disabling centerContent, reset inset to prop value
+ // (enabling is handled automatically by the setCenterContent: setter)
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
+ }
+
+ // Only apply contentInset from props if centerContent is disabled
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
}
}
- return isPointInside ? self : nil;
+ return isPointInside ? _scrollView : nil;
}
/*
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
return _virtualViewContainerState;
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
@end
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -60,6 +61,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
}
}
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
@end
#endif // RCT_REMOVE_LEGACY_ARCH
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -24,11 +24,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
index 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
@@ -459,6 +459,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
inSubviewClippingLoop = true
var clippedSoFar = 0
for (i in 0..<allChildrenCount) {
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
+ // an index below allChildrenCount. A null entry means the view is already detached, so
+ // treat it as clipped instead of crashing.
+ if (childArray[i] == null) {
+ clippedSoFar++
+ continue
+ }
try {
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
} catch (ex: IndexOutOfBoundsException) {
@@ -496,7 +503,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
) {
assertOnUiThread()
- val child = checkNotNull(allChildren?.get(idx))
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
+ // index can point at a null slot. Skip it instead of crashing.
+ val child = allChildren?.get(idx) ?: return
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
var needUpdateClippingRecursive = false
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
if (font != nullptr) {
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
} else {
// Failback to system font.
font = RCTDefaultFontWithFontProperties(fontProperties);
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
+ CGFloat epsilon = 0.001;
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
@@ -371,11 +197,225 @@ index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90
}
}
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
index a087536f3af0d33b13fe38d8abd1bc6d7935def2..01f5c884ea4772350c0ebe6263723d97632f2b74 100644
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
@@ -396,7 +396,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
MAP_SCROLL_VIEW_PROP(zoomScale);
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
+ // When disabling centerContent, reset inset to prop value
+ // (enabling is handled automatically by the setCenterContent: setter)
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
+ }
+
+ // Only apply contentInset from props if centerContent is disabled
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
}
@@ -523,7 +531,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
}
}
- return isPointInside ? self : nil;
+ return isPointInside ? _scrollView : nil;
}
/*
@@ -1133,6 +1141,11 @@ - (RCTVirtualViewContainerState *)virtualViewContainerState
return _virtualViewContainerState;
}
++ (BOOL)shouldBeRecycled
+{
+ return NO;
+}
+
@end
Class<RCTComponentViewProtocol> RCTScrollViewCls(void)
diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
index b033b7c71914d287470b7b86bd6bf39d311294ba..7e10dc929147fa4474ca9f955f5cd85d27aea935 100644
--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
@@ -827,9 +827,17 @@ static void RCTAddContourEffectToLayer(
} else {
CGSize imageSize = image.size;
UIEdgeInsets imageCapInsets = image.capInsets;
+ // The stretchable middle is whatever lies between the cap insets. The image
+ // may be larger than capInsets + 1 (its size is ceil'd to whole points), so
+ // deriving the middle from the caps rather than assuming a 1pt band keeps
+ // the bottom/right caps at their true size. A phantom cap here makes the
+ // caps overflow sub-pixel-sized layers (e.g. hairline borders), and the
+ // squeezed mesh + nearest-neighbor filtering drops the stroke entirely.
CGRect contentsCenter = CGRect{
CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height},
- CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}};
+ CGSize{
+ (imageSize.width - imageCapInsets.left - imageCapInsets.right) / imageSize.width,
+ (imageSize.height - imageCapInsets.top - imageCapInsets.bottom) / imageSize.height}};
layer.contents = (id)image.CGImage;
layer.contentsScale = image.scale;
diff --git a/React/Views/RefreshControl/RCTRefreshControl.h b/React/Views/RefreshControl/RCTRefreshControl.h
index ed306d7cadbf36a2fed79be8bd9d68b5dca135bd..d447dad534fefa9fcbdbbde6dcbbdcddadd5a824 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/React/Views/RefreshControl/RCTRefreshControl.h
@@ -18,6 +18,7 @@ __attribute__((deprecated("This API will be removed along with the legacy archit
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
@end
diff --git a/React/Views/RefreshControl/RCTRefreshControl.m b/React/Views/RefreshControl/RCTRefreshControl.m
index 2dc86e464264c9450eef18d7b153d35bf6a5cc55..6661dc69a04766afa0284d6e83839b219e98cf57 100644
--- a/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/React/Views/RefreshControl/RCTRefreshControl.m
@@ -25,6 +25,7 @@ @implementation RCTRefreshControl {
UIColor *_titleColor;
CGFloat _progressViewOffset;
BOOL _hasMovedToWindow;
+ UIColor *_customTintColor;
}
- (instancetype)init
@@ -60,6 +61,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+
- (void)didMoveToWindow
{
[super didMoveToWindow];
@@ -225,6 +232,18 @@ - (void)refreshControlValueChanged
}
}
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
@end
#endif // RCT_REMOVE_LEGACY_ARCH
diff --git a/React/Views/RefreshControl/RCTRefreshControlManager.m b/React/Views/RefreshControl/RCTRefreshControlManager.m
index 1e9ff527f4e6691d716da624031113a397876981..44329c5422c6f24d8a437fa35c6f2bad6bf8622b 100644
--- a/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -24,11 +24,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
index 59775241c80bec99ad3ec080f2425aacc8900c24..426de3aa77cda2032d7b0991e2ca3f8482a438d3 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt
@@ -459,6 +459,13 @@ public open class ReactViewGroup public constructor(context: Context?) :
inSubviewClippingLoop = true
var clippedSoFar = 0
for (i in 0..<allChildrenCount) {
+ // Reentrant child removal during this loop can compact allChildren and leave a null at
+ // an index below allChildrenCount. A null entry means the view is already detached, so
+ // treat it as clipped instead of crashing.
+ if (childArray[i] == null) {
+ clippedSoFar++
+ continue
+ }
try {
updateSubviewClipStatus(clippingRect, i, clippedSoFar, excludedViewsSet)
} catch (ex: IndexOutOfBoundsException) {
@@ -496,7 +503,9 @@ public open class ReactViewGroup public constructor(context: Context?) :
) {
assertOnUiThread()
- val child = checkNotNull(allChildren?.get(idx))
+ // allChildren can be mutated reentrantly while a clipping pass is running, so a stale
+ // index can point at a null slot. Skip it instead of crashing.
+ val child = allChildren?.get(idx) ?: return
val intersects = clippingRect.intersects(child.left, child.top, child.right, child.bottom)
var needUpdateClippingRecursive = false
diff --git a/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
index fdabd7bab1f03966dc04ba9a462465daeccf8ae3..ef70011ee5c270fb2cac52f3a7408d1e87334145 100644
--- a/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
+++ b/ReactCommon/react/featureflags/ReactNativeFeatureFlagsOverridesOSSStable.h
@@ -21,6 +21,10 @@ class ReactNativeFeatureFlagsOverridesOSSStable : public ReactNativeFeatureFlags
{
return true;
}
+ bool enableSchedulerDelegateInvalidation() override
+ {
+ return true;
+ }
bool useTurboModules() override
{
return true;
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
index 9b04cadc22f5ae7b105f9f9875a242b53188cf03..b2b27626edc46625ac2372a13977d700948835b6 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm
@@ -361,7 +361,7 @@ static UIFontDescriptorSystemDesign RCTGetFontDescriptorSystemDesign(NSString *f
font = [UIFont fontWithName:fontProperties.family size:effectiveFontSize];
if (font != nullptr) {
fontNames = [UIFont fontNamesForFamilyName:font.familyName];
- fontWeight = (fontWeight != 0.0) ?: RCTGetFontWeight(font);
+ fontWeight = (fontWeight != 0.0) ? fontWeight : RCTGetFontWeight(font);
} else {
// Failback to system font.
font = RCTDefaultFontWithFontProperties(fontProperties);
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
index ac553045a9c0ce77e288277912538d9e131ebc01..d99c8f4db5a07f1e4ffe7e03ff23adce9c63137b 100644
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
@@ -389,8 +389,9 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
size.height = enumeratedLinesHeight;
}
- size = (CGSize){ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
+ CGFloat epsilon = 0.001;
+ size = (CGSize){ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForTextContainer:textContainer];
diff --git a/ReactCommon/react/renderer/uimanager/UIManager.cpp b/ReactCommon/react/renderer/uimanager/UIManager.cpp
index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492facf4ead221 100644
--- a/ReactCommon/react/renderer/uimanager/UIManager.cpp
+++ b/ReactCommon/react/renderer/uimanager/UIManager.cpp
@@ -532,25 +532,3 @@ std::shared_ptr<const ShadowNode> UIManager::findShadowNodeByTag_DEPRECATED(
@@ -530,30 +530,8 @@ std::shared_ptr<const ShadowNode> UIManager::findShadowNodeByTag_DEPRECATED(
auto shadowNode = std::shared_ptr<const ShadowNode>{};
shadowTreeRegistry_.enumerate([&](const ShadowTree& shadowTree, bool& stop) {
- // Obtain a pointer to the root node. The flag-gated path uses
- // getCurrentRevision() which keeps the root alive via shared_ptr for
@@ -403,25 +443,6 @@ index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492fac
- }
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
+ const auto* rootShadowNode = rootShadowNodeHolder.get();
diff --git a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
--- a/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
+++ b/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm
@@ -827,9 +827,17 @@
} else {
CGSize imageSize = image.size;
UIEdgeInsets imageCapInsets = image.capInsets;
+ // The stretchable middle is whatever lies between the cap insets. The image
+ // may be larger than capInsets + 1 (its size is ceil'd to whole points), so
+ // deriving the middle from the caps rather than assuming a 1pt band keeps
+ // the bottom/right caps at their true size. A phantom cap here makes the
+ // caps overflow sub-pixel-sized layers (e.g. hairline borders), and the
+ // squeezed mesh + nearest-neighbor filtering drops the stroke entirely.
CGRect contentsCenter = CGRect{
CGPoint{imageCapInsets.left / imageSize.width, imageCapInsets.top / imageSize.height},
- CGSize{(CGFloat)1.0 / imageSize.width, (CGFloat)1.0 / imageSize.height}};
+ CGSize{
+ (imageSize.width - imageCapInsets.left - imageCapInsets.right) / imageSize.width,
+ (imageSize.height - imageCapInsets.top - imageCapInsets.bottom) / imageSize.height}};
layer.contents = (id)image.CGImage;
layer.contentsScale = image.scale;
if (rootShadowNode != nullptr) {
const auto& children = rootShadowNode->getChildren();
+16
View File
@@ -1,5 +1,21 @@
# ***This second part of this patch is load bearing, do not remove.***
## Scheduler delegate invalidation - iOS use-after-free
Fixes Sentry issue APP-T28X: an `EXC_BAD_ACCESS` in
`Scheduler::uiManagerDidFinishTransaction` or
`Scheduler::uiManagerDidDispatchCommand` after a queued rendering update
outlives its captured raw `SchedulerDelegate` pointer.
React Native 0.86 contains the invalidation-token guard from
facebook/react-native#56680, but `enableSchedulerDelegateInvalidation` is false
for the stable release level used by Expo. Override only this flag in
`ReactNativeFeatureFlagsOverridesOSSStable` instead of opting the app into all
experimental React Native flags.
**TODO: Remove after upgrading to a React Native release that closes the
queued Scheduler delegate lifetime race by default.**
## UIManager.cpp Patch - Fabric focus navigation use-after-free
Fixes Sentry issue APP-T4H9: a SIGSEGV in
+339 -329
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -450,6 +450,7 @@ export type Events = {
'post:view': {
uri: string
authorDid: string
isReply: boolean
logContext:
| 'FeedItem'
| 'PostThreadItem'
@@ -1396,6 +1397,41 @@ export type Events = {
playlist: string
}
/**
* The playable video was meaningfully visible. This is an exposure event,
* not proof that playback started. Fires once per mounted video item.
*/
'video:impression': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
}
/**
* Playback advanced far enough to render the first frame. Preloading and
* merely becoming active do not count. Fires once per mounted video item;
* automatic loops do not produce another event.
*/
'video:playback:start': {
postUri?: string
postAuthorDid?: string
context: 'embed' | 'immersiveFeed'
presentation: 'video' | 'gif'
autoplay: boolean
}
/**
* The user activated a third-party media player. Cross-origin players do
* not expose confirmed playback consistently, so this must not be treated
* as equivalent to video:playback:start without an explicit methodology.
*/
'externalEmbed:playerActivated': {
postUri?: string
postAuthorDid?: string
source: string
playerType: string
mediaType: 'video' | 'audio' | 'gif' | 'other'
}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
+7 -3
View File
@@ -11,14 +11,18 @@ import {
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
* type guard.
*/
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
function markMemoized<T extends MergeableMetadata>(m: T): T {
// @ts-expect-error
m.__meta = true
return m
}
export function useMeta(metadata?: MergeableMetadata) {
const m = useMemo(() => metadata, [metadata])
if (!m) return
return markMemoized(m)
}
export function accountToSessionMetadata(
account: SessionAccount | undefined,
): SessionMetadata | undefined {
+2 -2
View File
@@ -362,9 +362,9 @@ export function Composer({
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
onScroll={e => {
if (IS_WEB) {
inputScrollSharedValue.value = (e.target as any).scrollTop
inputScrollSharedValue.set((e.target as any).scrollTop)
} else {
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
inputScrollSharedValue.set(e.nativeEvent.contentOffset.y)
}
}}
// @ts-expect-error web only
+1 -1
View File
@@ -219,7 +219,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
IS_IOS && align === 'platform' && a.text_center,
t.atoms.text_contrast_medium,
]}
numberOfLines={2}>
numberOfLines={1}>
{children}
</Text>
)
@@ -22,6 +22,7 @@ import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
type EmbedPlayerParams,
getEmbedPlayerMediaType,
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences'
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
import {Fill} from '#/components/Fill'
import {KeepAwake} from '#/components/KeepAwake'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {type app} from '#/lexicons'
@@ -121,9 +123,11 @@ function Player({
export function ExternalPlayer({
link,
params,
post,
}: {
link: app.bsky.embed.external.ViewExternal
params: EmbedPlayerParams
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
@@ -131,10 +135,31 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
const ax = useAnalytics()
const [isPlayerActive, setIsPlayerActive] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const activatePlayer = useCallback(() => {
if (!isPlayerActive) {
ax.metric('externalEmbed:playerActivated', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
source: params.source,
playerType: params.type,
mediaType: getEmbedPlayerMediaType(params.type),
})
}
setIsPlayerActive(true)
}, [
ax,
isPlayerActive,
params.source,
params.type,
post?.author.did,
post?.uri,
])
const aspect = useMemo(() => {
return getPlayerAspect({
type: params.type,
@@ -202,14 +227,14 @@ export function ExternalPlayer({
return
}
setIsPlayerActive(true)
activatePlayer()
},
[externalEmbedsPrefs, consentDialogControl, params.source],
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
)
const onAcceptConsent = useCallback(() => {
setIsPlayerActive(true)
}, [])
activatePlayer()
}, [activatePlayer])
return (
<>
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
export const ExternalEmbed = ({
link,
onOpen,
post,
style,
hideAlt,
}: {
link: app.bsky.embed.external.ViewExternal
onOpen?: () => void
post?: app.bsky.feed.defs.PostView
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
{embedPlayerParams?.isGif ? (
<ExternalGif link={link} params={embedPlayerParams} />
) : embedPlayerParams ? (
<ExternalPlayer link={link} params={embedPlayerParams} />
<ExternalPlayer
link={link}
params={embedPlayerParams}
post={post}
/>
) : undefined}
<View
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
setStatus,
setIsLoading,
setIsActive,
onPlaybackStart,
onError,
}: {
ref: React.Ref<{togglePlayback: () => void}>
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
setStatus: (status: 'playing' | 'paused') => void
setIsLoading: (isLoading: boolean) => void
setIsActive: (isActive: boolean) => void
onPlaybackStart: (autoplay: boolean) => void
/**
* Called with the native error message before the component throws to the
* surrounding error boundary.
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0)
const playbackStartTrackedRef = useRef(false)
const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0)
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
}
const isGif = embed.presentation === 'gif'
const autoplay = !autoplayDisabled && !isWithinMessage
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
autoplay={autoplay}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
onTimeRemainingChange={e => {
const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining)
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current,
timeRemaining,
)
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(
maxTimeRemainingSeconds.current - timeRemaining,
)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(autoplay)
}
if (!isGif && reportDialogMetadata) {
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
}
}}
onError={e => {
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
setActive: () => void
onScreen: boolean
lastKnownTime: React.RefObject<number | undefined>
onPlaybackStart: (autoplay: boolean) => void
}
export class HLSUnsupportedError extends Error {
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
setActive,
onScreen,
lastKnownTime,
onPlaybackStart,
}: VideoEmbedInnerWebProps) {
const containerRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext()
const playbackStartTrackedRef = useRef(false)
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
onTimeUpdate={e => {
const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime
if (
!playbackStartTrackedRef.current &&
hasPlaybackStarted(currentTime)
) {
playbackStartTrackedRef.current = true
onPlaybackStart(!focused)
}
if (
!isGif &&
reportDialogMetadata &&
+26 -3
View File
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}
export function VideoEmbed({embed}: Props) {
export function VideoEmbed({embed, post}: Props) {
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
const contents = (
<ErrorBoundary renderError={renderError} key={key}>
<InnerWrapper embed={embed} />
<InnerWrapper embed={embed} post={post} />
</ErrorBoundary>
)
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
)
}
function InnerWrapper({embed}: Props) {
function InnerWrapper({embed, post}: Props) {
const {_} = useLingui()
const ax = useAnalytics()
const ref = useRef<{togglePlayback: () => void}>(null)
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
* the active position cost nothing.
*/
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
useEffect(() => {
return () => {
telemetryRef.current?.deactivated()
@@ -121,6 +124,15 @@ function InnerWrapper({embed}: Props) {
setIsActive={active => {
setIsActive(active)
if (active) {
if (!impressionTrackedRef.current) {
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
if (telemetryRef.current == null) {
telemetryRef.current = createPlaybackTelemetry({
surface: 'feed',
@@ -132,6 +144,17 @@ function InnerWrapper({embed}: Props) {
telemetryRef.current?.deactivated()
}
}}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay,
})
}}
onError={error => {
telemetryRef.current?.error(error)
ax.metric('video:playback:failed', {
@@ -37,7 +37,13 @@ const noop = () => {}
*/
const MIN_CARD_WIDTH = 280
export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
export function VideoEmbed({
embed,
post,
}: {
embed: app.bsky.embed.video.View
post?: app.bsky.feed.defs.PostView
}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null)
const {
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
currentActiveView,
} = useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
const [isFullscreen] = useFullscreen()
const lastKnownTime = useRef<number | undefined>(undefined)
const impressionTrackedRef = useRef(false)
const playbackStartTrackedRef = useRef(false)
const ax = useAnalytics()
const isGif = embed.presentation === 'gif'
// GIFs don't participate in the "one video at a time" system
const active = isGif || activeFromContext
useEffect(() => {
if (!meaningfullyVisible || impressionTrackedRef.current) return
impressionTrackedRef.current = true
ax.metric('video:impression', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
})
}, [ax, isGif, meaningfullyVisible, post?.author.did, post?.uri])
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !IS_WEB_FIREFOX) return
@@ -62,6 +83,9 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
const entry = entries[0]
if (!entry) return
setOnScreen(entry.isIntersecting)
setMeaningfullyVisible(
entry.isIntersecting && entry.intersectionRatio >= 0.5,
)
// GIFs don't send position - they don't compete to be the active video
if (!isGif) {
sendPosition(
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
setActive={setActive}
onScreen={onScreen}
lastKnownTime={lastKnownTime}
onPlaybackStart={autoplay => {
if (playbackStartTrackedRef.current) return
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post?.uri,
postAuthorDid: post?.author.did,
context: 'embed',
presentation: isGif ? 'gif' : 'video',
autoplay,
})
}}
/>
</OnlyNearScreen>
</ErrorBoundary>
+2 -1
View File
@@ -134,6 +134,7 @@ function MediaEmbed({
<ExternalEmbed
link={embed.view.external}
onOpen={rest.onOpen}
post={rest.post}
style={[a.mt_sm, rest.style]}
/>
</ContentHider>
@@ -144,7 +145,7 @@ function MediaEmbed({
<ContentHider
modui={rest.moderation?.ui('contentMedia')}
activeStyle={[a.mt_sm]}>
<VideoEmbed embed={embed.view} />
<VideoEmbed embed={embed.view} post={rest.post} />
</ContentHider>
)
}
+7 -3
View File
@@ -2,6 +2,7 @@ import {useMemo} from 'react'
import {type StyleProp, type TextStyle} from 'react-native'
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
import {isRTLText} from '#/lib/strings/text-direction'
import {toShortUrl} from '#/lib/strings/url-helpers'
import {android, atoms as a, flatten, type TextStyleProp} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
@@ -9,6 +10,7 @@ import {InlineLinkText, type LinkProps} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichTextTag} from '#/components/RichTextTag'
import {Text, type TextProps} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
@@ -82,15 +84,17 @@ export function RichText({
}
}, [value])
const plainStyles = style
const {text, facets} = richText
const plainStyles: StyleProp<TextStyle> = [
style,
IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null,
]
const suffixStyles =
suffix && suffixOffset
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
: null
const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText
if (!facets?.length) {
if (isOnlyEmoji(text)) {
const flattenedStyle = flatten(style)
+4 -3
View File
@@ -53,14 +53,15 @@ function ShareDialogInner({
const imageUrl = getStarterPackOgCard(starterPack)
const onShareLink = async () => {
const onShareLink = () => {
if (!link) return
shareUrl(link)
ax.metric('starterPack:share', {
starterPack: starterPack.uri,
shareType: 'link',
})
control.close()
control.close(() => {
void shareUrl(link)
})
}
const saveImageToAlbum = useSaveImageToMediaLibrary()
+2 -2
View File
@@ -8,7 +8,7 @@ import {Trans} from '@lingui/react/macro'
import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
@@ -103,7 +103,7 @@ function EmbedDialogInner({
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
return (
<Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
<Dialog.Inner label={_(msg`Embed post`)} style={[web({maxWidth: 500})]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>
+2 -2
View File
@@ -8,7 +8,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
@@ -45,7 +45,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
return (
<Dialog.ScrollableInner
label={_(msg`Sign in to Bluesky or create a new account`)}
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
style={[a.w_full, gtMobile && web({width: 'auto', maxWidth: 420})]}>
<View style={[!IS_NATIVE && a.p_2xl]}>
<View
style={[
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
@@ -38,7 +38,8 @@ export function InitialVerificationAnnouncement() {
<Dialog.ScrollableInner
label={_(msg`Announcing verification on Bluesky`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.align_start, a.gap_xl]}>
<View
+5 -4
View File
@@ -116,14 +116,15 @@ function Inner({
setActiveNux(undefined)
}, [activeNux, setActiveNux])
if (__DEV__ && typeof window !== 'undefined') {
// @ts-expect-error
useEffect(() => {
if (!__DEV__ || typeof window === 'undefined') return
// @ts-expect-error debug only
window.clearNuxDialog = (id: Nux) => {
if (!__DEV__ || !id) return
if (!id) return
resetNuxs([id])
unsnooze()
}
}
}, [resetNuxs])
useEffect(() => {
if (snoozed) return // comment this out to test
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {usePdsClient, useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
@@ -62,7 +62,8 @@ function Inner({}: {control: DialogControlProps}) {
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_xl]}>
{status === 'loading' ? (
+20 -27
View File
@@ -101,35 +101,28 @@ function ContentHiderActive({
}
}
const selfBlurCauses = []
let hasAdultContentLabel = false
const selfBlurNames = modui.blurs
.filter(cause => {
if (cause.type !== 'label') {
return false
}
if (cause.source.type !== 'user') {
return false
}
if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
if (hasAdultContentLabel) {
return false
}
hasAdultContentLabel = true
}
return true
})
.slice(0, 2)
.map(cause => {
if (cause.type !== 'label') {
return
}
for (const cause of modui.blurs) {
if (cause.type !== 'label') continue
if (cause.source.type !== 'user') continue
if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
if (hasAdultContentLabel) continue
hasAdultContentLabel = true
}
selfBlurCauses.push(cause)
}
const selfBlurNames = selfBlurCauses.slice(0, 2).map(cause => {
if (cause.type !== 'label') {
return
}
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
if (def.identifier === 'porn' || def.identifier === 'sexual') {
return l`Adult Content`
}
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
})
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
if (def.identifier === 'porn' || def.identifier === 'sexual') {
return l`Adult Content`
}
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
})
if (selfBlurNames.length === 0) {
return desc.name
@@ -18,7 +18,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {useMyLabelersQuery} from '#/state/queries/preferences'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {atoms as a, useGutters, useTheme, web} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -334,7 +334,7 @@ function Inner(
testID="report:dialog"
label={l`Report dialog`}
ref={ref}
style={[a.w_full, {maxWidth: 500}]}>
style={[a.w_full, web({maxWidth: 500})]}>
<View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}>
<StepOuter>
<StepTitle
@@ -8,7 +8,7 @@ import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -78,7 +78,8 @@ function Inner({
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold, a.pr_4xl, a.leading_tight]}>
@@ -7,7 +7,7 @@ import {Trans} from '@lingui/react/macro'
import {urls} from '#/lib/constants'
import {getUserDisplayName} from '#/lib/getUserDisplayName'
import {useSession} from '#/state/session'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {VerifierCheck} from '#/components/icons/VerifierCheck'
@@ -65,7 +65,8 @@ function Inner({
<Dialog.ScrollableInner
label={label}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
a.w_full,
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
]}>
<View style={[a.gap_lg]}>
<View
+8
View File
@@ -1,2 +1,10 @@
// TS6.0 enables noUncheckedSideEffectImports
declare module '*.css'
declare module 'bidi-js' {
type Bidi = {
getBidiCharTypeName(character: string): string
}
export default function bidiFactory(): Bidi
}
+54
View File
@@ -0,0 +1,54 @@
import {type app} from '#/lexicons'
import {createFeedViewPostsSlices} from './feed-manip'
jest.mock('./feed/home', () => ({
FALLBACK_MARKER_POST: {post: {uri: 'at://did:plc:test/app.bsky.feed.post/1'}},
}))
const author = {
$type: 'app.bsky.actor.defs#profileViewBasic',
did: 'did:plc:alice',
handle: 'alice.test',
} as app.bsky.actor.defs.ProfileViewBasic
function post(id: string) {
return {
$type: 'app.bsky.feed.defs#postView',
uri: `at://did:plc:alice/app.bsky.feed.post/${id}`,
cid: id,
author,
record: {
$type: 'app.bsky.feed.post',
text: id,
createdAt: '2026-08-31T00:00:00.000Z',
},
indexedAt: '2026-08-31T00:00:00.000Z',
} as app.bsky.feed.defs.PostView
}
describe('createFeedViewPostsSlices', () => {
it('preserves selected numbering and infers hydrated parent and root numbering', () => {
const root = post('root')
const parent = post('parent')
const selected = post('selected')
const feedPost = {
post: selected,
reply: {root, parent},
opThreadPostIndex: 3,
opThreadPostCount: 4,
} as app.bsky.feed.defs.FeedViewPost & {
opThreadPostIndex: number
opThreadPostCount: number
}
const [slice] = createFeedViewPostsSlices([feedPost])
expect(
slice.items.map(item => [item.post.uri, item.postNumbering]),
).toEqual([
[root.uri, {opThreadPostIndex: 1, opThreadPostCount: 4}],
[parent.uri, {opThreadPostIndex: 2, opThreadPostCount: 4}],
[selected.uri, {opThreadPostIndex: 3, opThreadPostCount: 4}],
])
})
})
+20 -14
View File
@@ -9,7 +9,7 @@ export type FeedPostNumbering = Pick<
'opThreadPostIndex' | 'opThreadPostCount'
>
type ValidFeedPostNumbering = Required<FeedPostNumbering>
export type ValidFeedPostNumbering = Required<FeedPostNumbering>
// AppView adds these fields to feed responses ahead of their feed lexicon.
type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering
@@ -62,7 +62,7 @@ export type FeedTunerFn = (
type FeedSliceItem = {
post: app.bsky.feed.defs.PostView
record: app.bsky.feed.post.Main
postNumbering: FeedPostNumbering | undefined
postNumbering: ValidFeedPostNumbering | undefined
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
isParentBlocked: boolean
isParentNotFound: boolean
@@ -88,7 +88,7 @@ export class FeedViewPostsSlice {
constructor(
feedPost: FeedViewPost,
postNumberingByUri: Map<string, FeedPostNumbering>,
postNumberingByUri: Map<string, ValidFeedPostNumbering>,
) {
const {post, reply, reason} = feedPost
this.items = []
@@ -286,6 +286,22 @@ export class FeedViewPostsSlice {
}
}
export function createFeedViewPostsSlices(
feed: FeedViewPost[],
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, ValidFeedPostNumbering>()
for (const item of feed) {
const postNumbering = getPostNumbering(item)
if (postNumbering) {
postNumberingByUri.set(item.post.uri, postNumbering)
}
}
return feed
.map(item => new FeedViewPostsSlice(item, postNumberingByUri))
.filter(slice => slice.items.length > 0 || slice.isFallbackMarker)
}
export class FeedTuner {
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
@@ -299,17 +315,7 @@ export class FeedTuner {
dryRun: false,
},
): FeedViewPostsSlice[] {
const postNumberingByUri = new Map<string, FeedPostNumbering>()
for (const item of feed) {
const postNumbering = getPostNumbering(item)
if (postNumbering) {
postNumberingByUri.set(item.post.uri, postNumbering)
}
}
let slices: FeedViewPostsSlice[] = feed
.map(item => new FeedViewPostsSlice(item, postNumberingByUri))
.filter(s => s.items.length > 0 || s.isFallbackMarker)
let slices = createFeedViewPostsSlices(feed)
// run the custom tuners
for (const tunerFn of this.tunerFns) {
+1
View File
@@ -24,6 +24,7 @@ export function usePostViewTracking(
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!post.record.reply,
logContext,
})
},
@@ -0,0 +1,14 @@
import {hasPlaybackStarted} from '../analytics'
describe('hasPlaybackStarted', () => {
it.each([
[0, false],
[0.049, false],
[0.05, true],
[1, true],
[Number.NaN, false],
[Number.POSITIVE_INFINITY, false],
])('returns %s for %s seconds', (seconds, expected) => {
expect(hasPlaybackStarted(seconds)).toBe(expected)
})
})
+13
View File
@@ -0,0 +1,13 @@
export const PLAYBACK_START_THRESHOLD_SECONDS = 0.05
/**
* A small positive threshold distinguishes rendered playback from metadata
* loading and zero-valued player callbacks while still representing the first
* frame across the frame rates we support.
*/
export function hasPlaybackStarted(progressSeconds: number): boolean {
return (
Number.isFinite(progressSeconds) &&
progressSeconds >= PLAYBACK_START_THRESHOLD_SECONDS
)
}
@@ -0,0 +1,24 @@
import {describe, expect, it} from '@jest/globals'
import {isRTLText} from '../text-direction'
describe('isRTLText', () => {
it('recognizes right-to-left text', () => {
expect(isRTLText('עברית')).toBe(true)
expect(isRTLText('العربية')).toBe(true)
})
it('recognizes left-to-right text', () => {
expect(isRTLText('English')).toBe(false)
})
it('uses the first strong directional character', () => {
expect(isRTLText(' 123 🦋 עברית English')).toBe(true)
expect(isRTLText(' 123 🦋 English עברית')).toBe(false)
})
it('defaults to left-to-right when there are no strong characters', () => {
expect(isRTLText('123 🦋 ...')).toBe(false)
expect(isRTLText('')).toBe(false)
})
})
+20
View File
@@ -0,0 +1,20 @@
import {type EmbedPlayerType, getEmbedPlayerMediaType} from './embed-player'
describe('getEmbedPlayerMediaType', () => {
it.each<
readonly [EmbedPlayerType, ReturnType<typeof getEmbedPlayerMediaType>]
>([
['youtube_video', 'video'],
['youtube_short', 'video'],
['twitch_video', 'video'],
['vimeo_video', 'video'],
['spotify_song', 'audio'],
['soundcloud_set', 'audio'],
['apple_music_album', 'audio'],
['bandcamp_track', 'audio'],
['giphy_gif', 'gif'],
['flickr_album', 'other'],
])('classifies %s as %s', (type, expected) => {
expect(getEmbedPlayerMediaType(type)).toBe(expected)
})
})
+23
View File
@@ -49,6 +49,29 @@ export type EmbedPlayerType =
| 'bandcamp_album'
| 'bandcamp_track'
export function getEmbedPlayerMediaType(
type: EmbedPlayerType,
): 'video' | 'audio' | 'gif' | 'other' {
if (
type === 'youtube_video' ||
type === 'youtube_short' ||
type === 'twitch_video' ||
type === 'vimeo_video'
) {
return 'video'
}
if (type.endsWith('_gif')) return 'gif'
if (
type.startsWith('spotify_') ||
type.startsWith('soundcloud_') ||
type.startsWith('apple_music_') ||
type.startsWith('bandcamp_')
) {
return 'audio'
}
return 'other'
}
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts',
+19
View File
@@ -0,0 +1,19 @@
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {DISCOVER_FEED_URI, TIMELINE_SAVED_FEED} from '#/lib/constants'
type FeedNameSource = {
displayName: string
uri: string
}
export function getLocalizedFeedName(feed: FeedNameSource, i18n: I18n): string {
if (feed.uri === TIMELINE_SAVED_FEED.value) {
return i18n._(msg({message: 'Following', context: 'feed-name'}))
}
if (feed.uri === DISCOVER_FEED_URI) {
return i18n._(msg({message: 'Discover', context: 'feed-name'}))
}
return feed.displayName
}
+17
View File
@@ -0,0 +1,17 @@
import bidiFactory from 'bidi-js'
const bidi = bidiFactory()
/**
* Checks the first strong directional character, matching HTML `dir="auto"`.
*/
export function isRTLText(text: string) {
for (const character of text) {
const type = bidi.getBidiCharTypeName(character)
if (type === 'R' || type === 'AL') return true
if (type === 'L') return false
}
return false
}
+7
View File
@@ -0,0 +1,7 @@
/**
* React Native Web sets `dir="auto"` on root Text elements, so the browser
* handles direction detection without JavaScript.
*/
export function isRTLText(_text: string) {
return false
}
File diff suppressed because it is too large Load Diff
@@ -395,11 +395,13 @@ export function CustomFeedHeader({
) : null}
</Layout.Header.Outer>
</Layout.Center>
<Dialog.Outer control={infoControl}>
<Dialog.Outer
control={infoControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Feed menu`}
style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
style={[a.w_full, gtMobile && web({width: 'auto', minWidth: 450})]}>
<DialogInner
info={info}
likeUri={likeUri}
@@ -329,6 +329,7 @@ export function StepProfile() {
{
width: 'auto',
maxWidth: 410,
marginHorizontal: 'auto',
},
]}>
<View style={[a.align_center, {paddingTop: 20}]}>
+1
View File
@@ -104,6 +104,7 @@ export function PostThread({uri}: {uri: string}) {
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!post.record.reply,
logContext: 'Post',
feedDescriptor: feedFeedback.feedDescriptor,
})
+37 -1
View File
@@ -44,6 +44,7 @@ import {HITSLOP_20} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
import {
createPlaybackTelemetry,
type PlaybackTelemetry,
@@ -492,9 +493,19 @@ let VideoItem = ({
const {width, height} = useSafeAreaFrame()
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
const hasTrackedView = useRef(false)
const hasTrackedVideoImpression = useRef(false)
useEffect(() => {
if (active) {
if (!hasTrackedVideoImpression.current) {
hasTrackedVideoImpression.current = true
ax.metric('video:impression', {
postUri: post.uri,
postAuthorDid: post.author.did,
context: 'immersiveFeed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
})
}
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionSeen',
@@ -508,6 +519,7 @@ let VideoItem = ({
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!post.record.reply,
logContext: 'ImmersiveVideo',
feedDescriptor,
})
@@ -518,6 +530,7 @@ let VideoItem = ({
active,
post.uri,
post.author.did,
embed.presentation,
feedContext,
reqId,
sendInteraction,
@@ -556,7 +569,12 @@ let VideoItem = ({
<>
<VideoItemPlaceholder embed={embed} />
{shouldRenderVideo && player && (
<VideoItemInner player={player} embed={embed} active={active} />
<VideoItemInner
player={player}
embed={embed}
post={post}
active={active}
/>
)}
{moderation && (
<Overlay
@@ -586,16 +604,20 @@ VideoItem = memo(VideoItem)
function VideoItemInner({
player,
embed,
post,
active,
}: {
player: VideoPlayer
embed: app.bsky.embed.video.View
post: app.bsky.feed.defs.PostView
active: boolean
}) {
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
const reportDialogMetadata =
ReportDialogMetadataContext.useReportDialogMetadataContext()
const ax = useAnalytics()
const playbackStartTrackedRef = useRef(false)
usePlaybackTelemetry({player, active, playlist: embed.playlist})
@@ -616,6 +638,20 @@ function VideoItemInner({
) {
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime
}
if (
active &&
!playbackStartTrackedRef.current &&
hasPlaybackStarted(evt.currentTime)
) {
playbackStartTrackedRef.current = true
ax.metric('video:playback:start', {
postUri: post.uri,
postAuthorDid: post.author.did,
context: 'immersiveFeed',
presentation: embed.presentation === 'gif' ? 'gif' : 'video',
autoplay: true,
})
}
})
return (
+34 -1
View File
@@ -11,7 +11,11 @@ import {
import {CustomFeedAPI} from '#/lib/api/feed/custom'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner} from '#/lib/api/feed-manip'
import {
createFeedViewPostsSlices,
FeedTuner,
type ValidFeedPostNumbering,
} from '#/lib/api/feed-manip'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
@@ -403,6 +407,35 @@ export function* findAllPostsInQueryData(
}
}
export function findPostNumberingInQueryData(
queryClient: QueryClient,
uri: string,
): ValidFeedPostNumbering | undefined {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<
InfiniteData<{
feed: app.bsky.feed.defs.GeneratorView
posts: app.bsky.feed.defs.FeedViewPost[]
}>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) continue
for (const page of queryData.pages) {
for (const slice of createFeedViewPostsSlices(page.posts)) {
for (const item of slice.items) {
if (item.postNumbering && didOrHandleUriMatches(atUri, item.post)) {
return item.postNumbering
}
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
+28
View File
@@ -26,9 +26,11 @@ import {PostListFeedAPI} from '#/lib/api/feed/posts'
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {
createFeedViewPostsSlices,
type FeedPostNumbering,
FeedTuner,
type FeedTunerFn,
type ValidFeedPostNumbering,
} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
import {logger} from '#/logger'
@@ -527,6 +529,32 @@ export function* findAllPostsInQueryData(
}
}
export function findPostNumberingInQueryData(
queryClient: QueryClient,
uri: string,
): ValidFeedPostNumbering | undefined {
const atUri = new AtUri(uri)
const queryDatas = queryClient.getQueriesData<
InfiniteData<FeedPageUnselected>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) continue
for (const page of queryData.pages) {
for (const slice of createFeedViewPostsSlices(page.feed)) {
for (const item of slice.items) {
if (item.postNumbering && didOrHandleUriMatches(atUri, item.post)) {
return item.postNumbering
}
}
}
}
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
@@ -0,0 +1,117 @@
import {QueryClient} from '@tanstack/react-query'
import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery'
import {
findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
findPostNumberingInQueryData as findPostNumberingInExploreFeedPreviewsQueryData,
} from '#/state/queries/explore-feed-previews'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
import {
findAllPostsInQueryData as findAllPostsInFeedQueryData,
findPostNumberingInQueryData as findPostNumberingInFeedQueryData,
} from '#/state/queries/post-feed'
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2'
import {type app} from '#/lexicons'
import {getThreadPlaceholder} from './queryCache'
jest.mock('#/state/cache/post-shadow', () => ({
dangerousGetPostShadow: jest.fn(),
updatePostShadow: jest.fn(),
}))
jest.mock('#/state/queries/bookmarks/useBookmarksQuery', () => ({
findAllPostsInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/explore-feed-previews', () => ({
findAllPostsInQueryData: jest.fn(),
findPostNumberingInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/notifications/feed', () => ({
findAllPostsInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/post-feed', () => ({
findAllPostsInQueryData: jest.fn(),
findPostNumberingInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/post-quotes', () => ({
findAllPostsInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/search-posts-v2', () => ({
findAllPostsInQueryData: jest.fn(),
}))
jest.mock('#/state/queries/usePostThread', () => ({
usePostThreadContext: jest.fn(),
}))
const finders = [
findAllPostsInBookmarksQueryData,
findAllPostsInExploreFeedPreviewsQueryData,
findAllPostsInNotifsQueryData,
findAllPostsInFeedQueryData,
findAllPostsInQuoteQueryData,
findAllPostsInSearchQueryData,
]
function post(uri: string, likeCount: number) {
return {
$type: 'app.bsky.feed.defs#postView',
uri,
likeCount,
} as app.bsky.feed.defs.PostView
}
describe('getThreadPlaceholder', () => {
const queryClient = new QueryClient()
beforeEach(() => {
jest.resetAllMocks()
for (const finder of finders) {
jest.mocked(finder).mockImplementation(function* () {})
}
jest.mocked(findPostNumberingInFeedQueryData).mockReturnValue(undefined)
jest
.mocked(findPostNumberingInExploreFeedPreviewsQueryData)
.mockReturnValue(undefined)
})
it('combines feed numbering with the preferred cached post', () => {
const uri = 'at://did:plc:alice/app.bsky.feed.post/1'
const notificationPost = post(uri, 4)
const feedPost = post(uri, 1)
jest.mocked(findPostNumberingInFeedQueryData).mockReturnValue({
opThreadPostIndex: 2,
opThreadPostCount: 4,
})
jest.mocked(findAllPostsInNotifsQueryData).mockImplementation(function* () {
yield notificationPost
return undefined
})
jest.mocked(findAllPostsInFeedQueryData).mockImplementation(function* () {
yield feedPost
return undefined
})
const placeholder = getThreadPlaceholder(queryClient, uri)
expect(placeholder?.value).toMatchObject({
post: notificationPost,
opThread: true,
opThreadPostIndex: 2,
opThreadPostCount: 4,
})
})
it('keeps non-numbered placeholders out of the OP thread', () => {
const uri = 'at://did:plc:alice/app.bsky.feed.post/1'
jest.mocked(findAllPostsInFeedQueryData).mockImplementation(function* () {
yield post(uri, 1)
return undefined
})
const placeholder = getThreadPlaceholder(queryClient, uri)
expect(placeholder?.value).toMatchObject({opThread: false})
expect(placeholder?.value).not.toHaveProperty('opThreadPostIndex')
expect(placeholder?.value).not.toHaveProperty('opThreadPostCount')
})
})
+25 -10
View File
@@ -3,14 +3,21 @@ import {type $Typed} from '@atproto/lex'
import {AtUri} from '@atproto/syntax'
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip'
import {
dangerousGetPostShadow,
updatePostShadow,
} from '#/state/cache/post-shadow'
import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery'
import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
import {
findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
findPostNumberingInQueryData as findPostNumberingInExploreFeedPreviewsQueryData,
} from '#/state/queries/explore-feed-previews'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
import {
findAllPostsInQueryData as findAllPostsInFeedQueryData,
findPostNumberingInQueryData as findPostNumberingInFeedQueryData,
} from '#/state/queries/post-feed'
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2'
import {usePostThreadContext} from '#/state/queries/usePostThread'
@@ -207,8 +214,15 @@ export function getThreadPlaceholder(
queryClient: QueryClient,
uri: string,
): $Typed<app.bsky.unspecced.getPostThreadV2.ThreadItem> | void {
const postNumbering =
findPostNumberingInFeedQueryData(queryClient, uri) ??
findPostNumberingInExploreFeedPreviewsQueryData(queryClient, uri)
let partial
for (let item of getThreadPlaceholderCandidates(queryClient, uri)) {
for (let item of getThreadPlaceholderCandidates(
queryClient,
uri,
postNumbering,
)) {
/*
* Currently, the backend doesn't send full post info in some cases (for
* example, for quoted posts). We use missing `likeCount` as a way to
@@ -231,6 +245,7 @@ export function getThreadPlaceholder(
export function* getThreadPlaceholderCandidates(
queryClient: QueryClient,
uri: string,
postNumbering?: ValidFeedPostNumbering,
): Generator<
$Typed<
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
@@ -243,7 +258,7 @@ export function* getThreadPlaceholderCandidates(
* Check post thread queries first
*/
for (const post of findAllPostsInQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
/*
@@ -253,25 +268,25 @@ export function* getThreadPlaceholderCandidates(
* avoid a notification->post scroll jump.
*/
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
queryClient,
uri,
)) {
yield postViewToThreadPlaceholder(post)
yield postViewToThreadPlaceholder(post, postNumbering)
}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import {type $Typed} from '@atproto/lex'
import {AtUri} from '@atproto/syntax'
import {moderatePost, type ModerationOpts} from '@bsky/sdk/moderation'
import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip'
import {makeProfileLink} from '#/lib/routes/links'
import {
type ApiThreadItem,
@@ -156,6 +157,7 @@ export function skeleton({
export function postViewToThreadPlaceholder(
post: app.bsky.feed.defs.PostView,
postNumbering?: ValidFeedPostNumbering,
): $Typed<
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
value: $Typed<app.bsky.unspecced.defs.ThreadItemPost>
@@ -168,7 +170,8 @@ export function postViewToThreadPlaceholder(
value: {
$type: 'app.bsky.unspecced.defs#threadItemPost',
post,
opThread: false,
opThread: !!postNumbering,
...postNumbering,
moreParents: false,
moreReplies: 0,
hiddenByThreadgate: false,
+2 -2
View File
@@ -78,14 +78,14 @@ describe('useAutoPagination', () => {
const itemCount = 0
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
for (let i = 1; i < 50; i++) {
for (let i = 1; i < 5; i++) {
value = query({
fetchNextPage,
data,
})
rerender(undefined)
}
expect(fetchNextPage).toHaveBeenCalledTimes(49)
expect(fetchNextPage).toHaveBeenCalledTimes(4)
const second = query({
data: {
+9 -1
View File
@@ -9,6 +9,14 @@ import {
import {app} from '#/lexicons'
import * as bsky from '#/types/bsky'
/**
* The appview does its own `fillPage`, and defaults to 10 pages. Previously
* the frontend tried up to 50 pages, thus the MAX_ATTEMPTS of 5 is a
* reasonable compromise to match pre-existing behavior and without blowing up
* our backend.
*/
const MAX_ATTEMPTS = 5
type AutoPaginationQuery = {
data?: {pageParams: unknown[]}
isLoading: boolean
@@ -75,7 +83,7 @@ export function useAutoPagination(
.some(param => Object.is(cursorOf(param), currentCursor))
if (repeatedCursor) return
attemptCount.current++
if (attemptCount.current < 50) {
if (attemptCount.current < MAX_ATTEMPTS) {
void query.fetchNextPage()
}
} else {
+5 -3
View File
@@ -683,9 +683,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const bundle = state.currentBundleState.bundle as unknown as
SessionBundle | PublicSessionBundle
// @ts-expect-error window type is not declared, debug only
// eslint-disable-next-line react-hooks/immutability
if (__DEV__ && IS_WEB) window.bundle = bundle
useEffect(() => {
if (!__DEV__ || !IS_WEB) return
// @ts-expect-error window type is not declared, debug only
window.bundle = bundle
}, [bundle])
const currentBundleRef = useRef(bundle)
/*
+4 -2
View File
@@ -1555,12 +1555,12 @@ export const ComposePost = ({
color="primary"
/>
)}
<Prompt.Cancel cta={l`Keep editing`} />
<Prompt.Action
cta={l`Discard`}
onPress={handleDiscard}
color="negative_subtle"
/>
<Prompt.Cancel cta={l`Keep editing`} />
</Prompt.Actions>
</Prompt.Outer>
)}
@@ -1681,6 +1681,8 @@ let ComposerPost = memo(function ComposerPost({
return (
<View
// Keep focused inputs attached while active-state opacity changes.
collapsable={false}
style={[
a.mx_lg,
a.mb_sm,
@@ -1700,7 +1702,7 @@ let ComposerPost = memo(function ComposerPost({
style={[a.pt_xs]}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus={isLastPost}
autoFocus={isActive}
webForceMinHeight={forceMinHeight}
// To avoid overlap with the close button:
hasRightPadding={isPartOfThread}
+7 -11
View File
@@ -1,6 +1,4 @@
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -30,7 +28,7 @@ export function DraftsButton({
canSaveDraft: boolean
textLength: number
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const draftsDialogControl = Dialog.useDialogControl()
const savePromptControl = Prompt.usePromptControl()
@@ -67,7 +65,7 @@ export function DraftsButton({
return (
<>
<Button
label={_(msg`Drafts`)}
label={l`Drafts`}
variant="ghost"
color="primary"
shape="default"
@@ -79,12 +77,10 @@ export function DraftsButton({
<Trans>Drafts</Trans>
</ButtonText>
</Button>
<DraftsListDialog
control={draftsDialogControl}
onSelectDraft={onSelectDraft}
/>
<Prompt.Outer control={savePromptControl}>
<Prompt.Content>
<Prompt.TitleText>
@@ -122,17 +118,17 @@ export function DraftsButton({
<Prompt.Actions>
{canSaveDraft && (
<Prompt.Action
cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)}
onPress={handleSaveAndOpen}
cta={isEditingDraft ? l`Save changes` : l`Save draft`}
onPress={() => void handleSaveAndOpen()}
color="primary"
/>
)}
<Prompt.Cancel cta={l`Keep editing`} />
<Prompt.Action
cta={_(msg`Discard`)}
cta={l`Discard`}
onPress={handleDiscardAndOpen}
color="negative_subtle"
/>
<Prompt.Cancel cta={_(msg`Keep editing`)} />
</Prompt.Actions>
</Prompt.Outer>
</>
+1 -1
View File
@@ -101,7 +101,7 @@ function DialogInner({
return (
<Dialog.ScrollableInner
label={_(msg`Add a content warning`)}
style={[{maxWidth: 500}, a.w_full]}>
style={[a.w_full, web({maxWidth: 500})]}>
<View style={[a.flex_1]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
@@ -0,0 +1,60 @@
import {composerReducer, createComposerState} from './composer'
jest.mock('#/state/gallery', () => ({
createInitialImages: jest.fn(),
}))
jest.mock('#/logger', () => ({
logger: {
warn: jest.fn(),
},
}))
jest.mock('#/state/queries/postgate/util', () => ({
createPostgateRecord: jest.fn(() => ({})),
}))
jest.mock('#/state/queries/threadgate', () => ({
threadgateRecordToAllowUISetting: jest.fn(() => []),
}))
function createState() {
return createComposerState({
initText: undefined,
initMention: undefined,
initImageUris: undefined,
initQuoteUri: undefined,
initInteractionSettings: undefined,
})
}
describe('composerReducer', () => {
describe('add_post', () => {
it('selects the appended post and requests focus', () => {
const state = createState()
const nextState = composerReducer(state, {type: 'add_post'})
expect(nextState.thread.posts).toHaveLength(2)
expect(nextState.activePostIndex).toBe(1)
expect(nextState.mutableNeedsFocusActive).toBe(true)
})
it('selects a post inserted in the middle of a thread', () => {
let state = createState()
state = composerReducer(state, {type: 'add_post'})
state = composerReducer(state, {type: 'add_post'})
const lastPostId = state.thread.posts[2].id
state = composerReducer(state, {
type: 'focus_post',
postId: state.thread.posts[0].id,
})
const nextState = composerReducer(state, {type: 'add_post'})
expect(nextState.thread.posts).toHaveLength(4)
expect(nextState.activePostIndex).toBe(1)
expect(nextState.thread.posts[2].id).not.toBe(lastPostId)
expect(nextState.thread.posts[3].id).toBe(lastPostId)
expect(nextState.mutableNeedsFocusActive).toBe(true)
})
})
})
+2
View File
@@ -253,6 +253,8 @@ export function composerReducer(
return {
...state,
isDirty: true,
activePostIndex: activePostIndex + 1,
mutableNeedsFocusActive: true,
thread: {
...state.thread,
posts: nextPosts,
+9 -5
View File
@@ -1,7 +1,10 @@
import {useCallback, useMemo} from 'react'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {TIMELINE_SAVED_FEED} from '#/lib/constants'
import {type NavigationProp} from '#/lib/routes/types'
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
import {type FeedSourceInfo} from '#/state/queries/feed'
import {useSession} from '#/state/session'
import {type RenderTabBarFnProps} from '#/view/com/pager/Pager'
@@ -12,28 +15,29 @@ export function HomeHeader(
props: RenderTabBarFnProps & {
testID?: string
onPressSelected: () => void
feeds: FeedSourceInfo[]
feeds: Pick<FeedSourceInfo, 'displayName' | 'uri'>[]
},
) {
const {feeds, onSelect: onSelectProp} = props
const {hasSession} = useSession()
const {t: l, i18n} = useLingui()
const navigation = useNavigation<NavigationProp>()
const hasPinnedCustom = useMemo<boolean>(() => {
if (!hasSession) return false
return feeds.some(tab => {
const isFollowing = tab.uri === 'following'
const isFollowing = tab.uri === TIMELINE_SAVED_FEED.value
return !isFollowing
})
}, [feeds, hasSession])
const items = useMemo(() => {
const pinnedNames = feeds.map(f => f.displayName)
const pinnedNames = feeds.map(f => getLocalizedFeedName(f, i18n))
if (!hasPinnedCustom) {
return pinnedNames.concat('Feeds ✨')
return pinnedNames.concat(l`Feeds ✨`)
}
return pinnedNames
}, [hasPinnedCustom, feeds])
}, [i18n, l, hasPinnedCustom, feeds])
const onPressFeedsLink = useCallback(() => {
navigation.navigate('Feeds')
+1 -1
View File
@@ -332,7 +332,7 @@ export function TabBar({
syncScrollState.set('unsynced')
}}
onScroll={e => {
scrollX.value = Math.round(e.nativeEvent.contentOffset.x)
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
}}>
<Animated.View
onLayout={e => {
+11 -3
View File
@@ -1077,15 +1077,22 @@ let PostFeed = ({
onPostSeen(post)
// Only track the root post of each slice (index 0) to avoid double-counting thread items
if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
// Track the post selected by the feed once it is actually visible.
if (
post.uri === slice.feedPostUri &&
!seenPostUrisRef.current.has(post.uri)
) {
seenPostUrisRef.current.add(post.uri)
const position = getPostPosition('sliceItem', item.key)
const position = getPostPosition(
'sliceItem',
slice.items[0]._reactKey,
)
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!postItem.record.reply,
logContext: 'FeedItem',
feedDescriptor: feedFeedback.feedDescriptor || feed,
position,
@@ -1121,6 +1128,7 @@ let PostFeed = ({
ax.metric('post:view', {
uri: post.uri,
authorDid: post.author.did,
isReply: !!postItem.record.reply,
logContext: 'FeedItem',
feedDescriptor: feedFeedback.feedDescriptor || feed,
position,
+18 -5
View File
@@ -4,9 +4,14 @@ import {
Reanimated3DefaultSpringConfig,
withSpring,
} from 'react-native-reanimated'
import {useLingui} from '@lingui/react/macro'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {
DISCOVER_FEED_URI,
PROD_DEFAULT_FEED,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
@@ -15,6 +20,7 @@ import {
type HomeTabNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
import {emitSoftReset} from '#/state/events'
import {
type SavedFeedSourceInfo,
@@ -114,6 +120,7 @@ function HomeScreenReady({
preferences: UsePreferencesQueryResponse
pinnedFeedInfos: SavedFeedSourceInfo[]
}) {
const {i18n} = useLingui()
const ax = useAnalytics()
const allFeeds = useMemo(
() => pinnedFeedInfos.map(f => f.feedDescriptor),
@@ -125,13 +132,14 @@ function HomeScreenReady({
const maybeFoundIndex = allFeeds.indexOf(maybeRawSelectedFeed)
const selectedIndex = Math.max(0, maybeFoundIndex)
const maybeSelectedFeed: FeedDescriptor | undefined = allFeeds[selectedIndex]
const selectedFeedInfo = pinnedFeedInfos[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useSetTitle(selectedFeedInfo && getLocalizedFeedName(selectedFeedInfo, i18n))
useOTAUpdates()
useEffect(() => {
requestNotificationsPermission('Home')
void requestNotificationsPermission('Home')
}, [requestNotificationsPermission])
const pagerRef = useRef<PagerRef>(null)
@@ -223,8 +231,13 @@ function HomeScreenReady({
{...props}
testID="homeScreenFeedTabs"
onPressSelected={onPressSelected}
// @ts-expect-error
feeds={[{displayName: 'Following'}, {displayName: 'Discover'}]}
feeds={[
{
displayName: 'Following',
uri: TIMELINE_SAVED_FEED.value,
},
{displayName: 'Discover', uri: DISCOVER_FEED_URI},
]}
/>
)
}
+10 -10
View File
@@ -1,10 +1,10 @@
import {Pressable, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useNavigation, useNavigationState} from '@react-navigation/native'
import {getCurrentRoute} from '#/lib/routes/helpers'
import {type NavigationProp} from '#/lib/routes/types'
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
import {emitSoftReset} from '#/state/events'
import {
type SavedFeedSourceInfo,
@@ -22,7 +22,7 @@ import {useAnalytics} from '#/analytics'
export function DesktopFeeds() {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const ax = useAnalytics()
const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos()
const selectedFeed = useSelectedFeed()
@@ -100,10 +100,9 @@ export function DesktopFeeds() {
/>
)
})}
<Link
to="/feeds"
label={_(msg`More feeds`)}
label={l`More feeds`}
style={[
a.flex_row,
a.align_center,
@@ -150,7 +149,7 @@ export function DesktopFeeds() {
: t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{_(msg`More feeds`)}
{l`More feeds`}
</Text>
</>
)
@@ -170,19 +169,20 @@ function FeedItem({
onPress: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {t: l, i18n} = useLingui()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const isFollowing = feedInfo.feedDescriptor === 'following'
const displayName = getLocalizedFeedName(feedInfo, i18n)
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={feedInfo.displayName}
accessibilityHint={_(msg`Opens ${feedInfo.displayName} feed`)}
accessibilityLabel={displayName}
accessibilityHint={l`Opens ${displayName} feed`}
onPress={onPress}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
@@ -231,7 +231,7 @@ function FeedItem({
: t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{feedInfo.displayName}
{displayName}
</Text>
</Pressable>
)