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:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+158
-24
@@ -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={[
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+339
-329
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+471
-465
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}]}>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
/*
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -253,6 +253,8 @@ export function composerReducer(
|
||||
return {
|
||||
...state,
|
||||
isDirty: true,
|
||||
activePostIndex: activePostIndex + 1,
|
||||
mutableNeedsFocusActive: true,
|
||||
thread: {
|
||||
...state.thread,
|
||||
posts: nextPosts,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user