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:
|
with:
|
||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
|
|
||||||
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
||||||
# like the build job so the toolchain is deterministic.
|
# 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:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: 🤖 Claude
|
- name: 🤖 Claude
|
||||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: 🤖 Claude review
|
- name: 🤖 Claude review
|
||||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: ☕️ Set up Java 17
|
- name: ☕️ Set up Java 17
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -176,7 +176,7 @@ jobs:
|
|||||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
- name: ☕️ Set up Java 17
|
- name: ☕️ Set up Java 17
|
||||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
commit_message: Nightly source-language update
|
commit_message: Nightly source-language update
|
||||||
file_pattern: ./src/locale/locales/en/messages.po
|
file_pattern: ./src/locale/locales/en/messages.po
|
||||||
- name: 🚀 Push source lang to Crowdin
|
- name: 🚀 Push source lang to Crowdin
|
||||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_sources_args: "-b main"
|
upload_sources_args: "-b main"
|
||||||
|
|||||||
+2
-2
@@ -426,7 +426,7 @@
|
|||||||
},
|
},
|
||||||
"app.bsky.embed.video": {
|
"app.bsky.embed.video": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.feed.defs": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.graph.defs": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/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": {
|
"app.bsky.graph.follow": {
|
||||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
|
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.graph.follow",
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"alt": {
|
"alt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 10000,
|
"description": "Alt text description of the video, for accessibility."
|
||||||
"description": "Alt text description of the video, for accessibility.",
|
|
||||||
"maxGraphemes": 1000
|
|
||||||
},
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"type": "blob",
|
"type": "blob",
|
||||||
@@ -51,9 +49,7 @@
|
|||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"alt": {
|
"alt": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"maxLength": 10000,
|
|
||||||
"maxGraphemes": 1000
|
|
||||||
},
|
},
|
||||||
"cid": {
|
"cid": {
|
||||||
"type": "string",
|
"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": {
|
"requestLess": {
|
||||||
"type": "token",
|
"type": "token",
|
||||||
"description": "Request that less content like the given feed item be shown in the feed"
|
"description": "Request that less content like the given feed item be shown in the feed"
|
||||||
@@ -194,6 +216,11 @@
|
|||||||
"bookmarked": {
|
"bookmarked": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"knownLikers": {
|
||||||
|
"ref": "#knownLikers",
|
||||||
|
"type": "ref",
|
||||||
|
"description": "This property is present only in selected cases, as an optimization."
|
||||||
|
},
|
||||||
"threadMuted": {
|
"threadMuted": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -202,37 +229,10 @@
|
|||||||
},
|
},
|
||||||
"embeddingDisabled": {
|
"embeddingDisabled": {
|
||||||
"type": "boolean"
|
"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."
|
"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": {
|
"feedViewPost": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
"subject": {
|
"subject": {
|
||||||
"ref": "app.bsky.actor.defs#profileView",
|
"ref": "app.bsky.actor.defs#profileView",
|
||||||
"type": "ref"
|
"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": {
|
"blocked": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "at-uri"
|
"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
|
- Preserves status/nav bar appearance from host activity
|
||||||
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
||||||
- Forwards touch events to React Native event system
|
- 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
|
- Based on React Native's ReactModalHostView pattern
|
||||||
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
|
- **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).
|
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
|
## Props
|
||||||
|
|
||||||
```typescript
|
```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.
|
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
|
### Platform Differences
|
||||||
|
|
||||||
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
- **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.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
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(
|
class BottomSheetView(
|
||||||
context: Context,
|
context: Context,
|
||||||
appContext: AppContext,
|
appContext: AppContext,
|
||||||
@@ -38,26 +44,30 @@ class BottomSheetView(
|
|||||||
private var lastObservedContentHeight: Float = 0f
|
private var lastObservedContentHeight: Float = 0f
|
||||||
private var pendingLayoutUpdate: Boolean = false
|
private var pendingLayoutUpdate: Boolean = false
|
||||||
|
|
||||||
private val screenHeight: Float =
|
// Computed per read rather than cached at construction: the RN activity handles
|
||||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
// configuration changes itself, so a rotation resizes the display without
|
||||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
// recreating this view and a cached value would stay stale for the sheet's life.
|
||||||
context.resources.displayMetrics.heightPixels
|
private val screenHeight: Float
|
||||||
.toFloat()
|
get() =
|
||||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
context.resources.displayMetrics.heightPixels
|
||||||
wm.currentWindowMetrics.bounds
|
.toFloat()
|
||||||
.height()
|
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
.toFloat()
|
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||||
} else {
|
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
wm.currentWindowMetrics.bounds
|
||||||
// which includes system bars (heightPixels may exclude them)
|
.height()
|
||||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
.toFloat()
|
||||||
val size = android.graphics.Point()
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||||
wm.defaultDisplay.getRealSize(size)
|
// which includes system bars (heightPixels may exclude them)
|
||||||
size.y.toFloat()
|
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 {
|
private fun getNavigationBarHeight(): Int {
|
||||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||||
@@ -73,6 +83,11 @@ class BottomSheetView(
|
|||||||
private val onSnapPointChange by EventDispatcher()
|
private val onSnapPointChange by EventDispatcher()
|
||||||
private val onStateChange 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
|
var disableDrag = false
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
@@ -99,10 +114,11 @@ class BottomSheetView(
|
|||||||
field = if (value < 0) 0f else dpToPx(value)
|
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) {
|
set(value) {
|
||||||
val px = dpToPx(value)
|
field = dpToPx(value)
|
||||||
field = if (px > this.screenHeight) this.screenHeight else px
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isOpen: Boolean = false
|
private var isOpen: Boolean = false
|
||||||
@@ -140,6 +156,38 @@ class BottomSheetView(
|
|||||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
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)
|
SheetManager.add(this)
|
||||||
}
|
}
|
||||||
@@ -151,9 +199,84 @@ class BottomSheetView(
|
|||||||
r: Int,
|
r: Int,
|
||||||
b: Int,
|
b: Int,
|
||||||
) {
|
) {
|
||||||
|
this.seedCanvasSize()
|
||||||
this.present()
|
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() {
|
private fun destroy() {
|
||||||
this.stopObservingContentHeight()
|
this.stopObservingContentHeight()
|
||||||
this.isClosing = false
|
this.isClosing = false
|
||||||
@@ -178,6 +301,15 @@ class BottomSheetView(
|
|||||||
|
|
||||||
val contentHeight = this.getContentHeight()
|
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 activityWindow: Window? = null
|
||||||
var currentContext = context
|
var currentContext = context
|
||||||
while (currentContext != null) {
|
while (currentContext != null) {
|
||||||
@@ -425,8 +557,10 @@ class BottomSheetView(
|
|||||||
|
|
||||||
private fun getTargetHeight(): Float {
|
private fun getTargetHeight(): Float {
|
||||||
val contentHeight = this.getContentHeight()
|
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 {
|
return when {
|
||||||
contentHeight > maxHeight -> maxHeight
|
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
|
||||||
contentHeight < minHeight -> minHeight
|
contentHeight < minHeight -> minHeight
|
||||||
else -> contentHeight
|
else -> contentHeight
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ const IS_IOS15 =
|
|||||||
Platform.OS === 'ios' &&
|
Platform.OS === 'ios' &&
|
||||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||||
Number(Platform.Version.split('.').at(0)) < 16
|
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<
|
export class BottomSheetNativeComponent extends Component<
|
||||||
BottomSheetViewProps,
|
BottomSheetViewProps,
|
||||||
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
|
|||||||
const {height: screenHeight} = useWindowDimensions()
|
const {height: screenHeight} = useWindowDimensions()
|
||||||
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
|
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 (
|
return (
|
||||||
<NativeView
|
<NativeView
|
||||||
{...rest}
|
{...rest}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
ref={nativeViewRef}
|
ref={nativeViewRef}
|
||||||
style={{
|
/*
|
||||||
position: 'absolute',
|
* On Android the native side owns this view's size - the canvas the sheet
|
||||||
height: sheetHeight,
|
* content is laid out on - and pushes it into the Fabric shadow tree through
|
||||||
width: '100%',
|
* 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}>
|
containerBackgroundColor={backgroundColor}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -317,14 +317,6 @@
|
|||||||
"count": 4
|
"count": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/components/StarterPack/ShareDialog.tsx": {
|
|
||||||
"typescript/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"typescript/require-await": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
|
"src/components/StarterPack/Wizard/WizardEditListDialog.tsx": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1318,11 +1310,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/com/composer/drafts/DraftsButton.tsx": {
|
|
||||||
"typescript/no-misused-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/com/composer/drafts/state/queries.ts": {
|
"src/view/com/composer/drafts/state/queries.ts": {
|
||||||
"typescript/no-explicit-any": {
|
"typescript/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1533,11 +1520,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/screens/Home.tsx": {
|
|
||||||
"typescript/no-floating-promises": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/screens/ModerationBlockedAccounts.tsx": {
|
"src/view/screens/ModerationBlockedAccounts.tsx": {
|
||||||
"typescript/no-misused-promises": {
|
"typescript/no-misused-promises": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
+2
-1
@@ -112,7 +112,7 @@
|
|||||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
||||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||||
"@bsky.app/peek-menu": "^0.3.2",
|
"@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/sift": "^0.3.9",
|
||||||
"@bsky.app/tapper": "^0.6.1",
|
"@bsky.app/tapper": "^0.6.1",
|
||||||
"@bsky.app/video": "0.3.6",
|
"@bsky.app/video": "0.3.6",
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"bcp-47": "^2.1.0",
|
"bcp-47": "^2.1.0",
|
||||||
"bcp-47-match": "^2.0.3",
|
"bcp-47-match": "^2.0.3",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"email-validator": "^2.0.4",
|
"email-validator": "^2.0.4",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
|
|||||||
+217
-196
@@ -23,180 +23,6 @@ index 1b02e8b2d39672063551411d5c403a69b671a869..b3481c1b98b45dea769035140dc2fd8d
|
|||||||
- (void)setFrame:(CGRect)frame
|
- (void)setFrame:(CGRect)frame
|
||||||
{
|
{
|
||||||
[super setFrame: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
|
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||||
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
|
index 60160efb163d91813fa2ca7ca758b51afcf261e1..fb646fe945ffe4aa4a386f80a1e42a90180691f1 100644
|
||||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
--- 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
|
diff --git a/ReactCommon/react/renderer/uimanager/UIManager.cpp b/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
||||||
index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492facf4ead221 100644
|
index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492facf4ead221 100644
|
||||||
--- a/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
--- a/ReactCommon/react/renderer/uimanager/UIManager.cpp
|
||||||
+++ b/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) {
|
shadowTreeRegistry_.enumerate([&](const ShadowTree& shadowTree, bool& stop) {
|
||||||
- // Obtain a pointer to the root node. The flag-gated path uses
|
- // Obtain a pointer to the root node. The flag-gated path uses
|
||||||
- // getCurrentRevision() which keeps the root alive via shared_ptr for
|
- // getCurrentRevision() which keeps the root alive via shared_ptr for
|
||||||
@@ -403,25 +443,6 @@ index 3e48dabc6fffc246fd0517ef5f3f2b7721115511..ea4ba5fdf359c513a5ca4f0e94492fac
|
|||||||
- }
|
- }
|
||||||
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
|
+ auto rootShadowNodeHolder = shadowTree.getCurrentRevision().rootShadowNode;
|
||||||
+ const auto* rootShadowNode = rootShadowNodeHolder.get();
|
+ 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.***
|
# ***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
|
## UIManager.cpp Patch - Fabric focus navigation use-after-free
|
||||||
|
|
||||||
Fixes Sentry issue APP-T4H9: a SIGSEGV in
|
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': {
|
'post:view': {
|
||||||
uri: string
|
uri: string
|
||||||
authorDid: string
|
authorDid: string
|
||||||
|
isReply: boolean
|
||||||
logContext:
|
logContext:
|
||||||
| 'FeedItem'
|
| 'FeedItem'
|
||||||
| 'PostThreadItem'
|
| 'PostThreadItem'
|
||||||
@@ -1396,6 +1397,41 @@ export type Events = {
|
|||||||
playlist: string
|
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) ===
|
// === Video upload funnel (Frontend Spec section D) ===
|
||||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||||
// session end-to-end) + engine (compression engine id, e.g.
|
// 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
|
* Thin `useMemo` wrapper that marks the metadata as memoized and provides a
|
||||||
* type guard.
|
* type guard.
|
||||||
*/
|
*/
|
||||||
export function useMeta(metadata?: MergeableMetadata) {
|
function markMemoized<T extends MergeableMetadata>(m: T): T {
|
||||||
const m = useMemo(() => metadata, [metadata])
|
|
||||||
if (!m) return
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
m.__meta = true
|
m.__meta = true
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMeta(metadata?: MergeableMetadata) {
|
||||||
|
const m = useMemo(() => metadata, [metadata])
|
||||||
|
if (!m) return
|
||||||
|
return markMemoized(m)
|
||||||
|
}
|
||||||
|
|
||||||
export function accountToSessionMetadata(
|
export function accountToSessionMetadata(
|
||||||
account: SessionAccount | undefined,
|
account: SessionAccount | undefined,
|
||||||
): SessionMetadata | undefined {
|
): SessionMetadata | undefined {
|
||||||
|
|||||||
@@ -362,9 +362,9 @@ export function Composer({
|
|||||||
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
onKeyPress={IS_WEB ? onKeyPressWeb : undefined}
|
||||||
onScroll={e => {
|
onScroll={e => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
inputScrollSharedValue.value = (e.target as any).scrollTop
|
inputScrollSharedValue.set((e.target as any).scrollTop)
|
||||||
} else {
|
} else {
|
||||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
inputScrollSharedValue.set(e.nativeEvent.contentOffset.y)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
|
|||||||
IS_IOS && align === 'platform' && a.text_center,
|
IS_IOS && align === 'platform' && a.text_center,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={2}>
|
numberOfLines={1}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {useNavigation} from '@react-navigation/native'
|
|||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {
|
import {
|
||||||
type EmbedPlayerParams,
|
type EmbedPlayerParams,
|
||||||
|
getEmbedPlayerMediaType,
|
||||||
getPlayerAspect,
|
getPlayerAspect,
|
||||||
} from '#/lib/strings/embed-player'
|
} from '#/lib/strings/embed-player'
|
||||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||||
@@ -32,6 +33,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
|||||||
import {Fill} from '#/components/Fill'
|
import {Fill} from '#/components/Fill'
|
||||||
import {KeepAwake} from '#/components/KeepAwake'
|
import {KeepAwake} from '#/components/KeepAwake'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {type app} from '#/lexicons'
|
import {type app} from '#/lexicons'
|
||||||
|
|
||||||
@@ -121,9 +123,11 @@ function Player({
|
|||||||
export function ExternalPlayer({
|
export function ExternalPlayer({
|
||||||
link,
|
link,
|
||||||
params,
|
params,
|
||||||
|
post,
|
||||||
}: {
|
}: {
|
||||||
link: app.bsky.embed.external.ViewExternal
|
link: app.bsky.embed.external.ViewExternal
|
||||||
params: EmbedPlayerParams
|
params: EmbedPlayerParams
|
||||||
|
post?: app.bsky.feed.defs.PostView
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
@@ -131,10 +135,31 @@ export function ExternalPlayer({
|
|||||||
const windowDims = useWindowDimensions()
|
const windowDims = useWindowDimensions()
|
||||||
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
||||||
const consentDialogControl = useDialogControl()
|
const consentDialogControl = useDialogControl()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
const [isPlayerActive, setIsPlayerActive] = useState(false)
|
const [isPlayerActive, setIsPlayerActive] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
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(() => {
|
const aspect = useMemo(() => {
|
||||||
return getPlayerAspect({
|
return getPlayerAspect({
|
||||||
type: params.type,
|
type: params.type,
|
||||||
@@ -202,14 +227,14 @@ export function ExternalPlayer({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlayerActive(true)
|
activatePlayer()
|
||||||
},
|
},
|
||||||
[externalEmbedsPrefs, consentDialogControl, params.source],
|
[externalEmbedsPrefs, consentDialogControl, params.source, activatePlayer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAcceptConsent = useCallback(() => {
|
const onAcceptConsent = useCallback(() => {
|
||||||
setIsPlayerActive(true)
|
activatePlayer()
|
||||||
}, [])
|
}, [activatePlayer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ import {GifEmbed} from './Gif'
|
|||||||
export const ExternalEmbed = ({
|
export const ExternalEmbed = ({
|
||||||
link,
|
link,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
post,
|
||||||
style,
|
style,
|
||||||
hideAlt,
|
hideAlt,
|
||||||
}: {
|
}: {
|
||||||
link: app.bsky.embed.external.ViewExternal
|
link: app.bsky.embed.external.ViewExternal
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
|
post?: app.bsky.feed.defs.PostView
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hideAlt?: boolean
|
hideAlt?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
@@ -120,7 +122,11 @@ export const ExternalEmbed = ({
|
|||||||
{embedPlayerParams?.isGif ? (
|
{embedPlayerParams?.isGif ? (
|
||||||
<ExternalGif link={link} params={embedPlayerParams} />
|
<ExternalGif link={link} params={embedPlayerParams} />
|
||||||
) : embedPlayerParams ? (
|
) : embedPlayerParams ? (
|
||||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
<ExternalPlayer
|
||||||
|
link={link}
|
||||||
|
params={embedPlayerParams}
|
||||||
|
post={post}
|
||||||
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {BlueskyVideoView} from '@bsky.app/video'
|
|||||||
import {useLingui} from '@lingui/react/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
|
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
|
||||||
import {useAutoplayDisabled} from '#/state/preferences'
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
@@ -26,6 +27,7 @@ export function VideoEmbedInnerNative({
|
|||||||
setStatus,
|
setStatus,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
setIsActive,
|
setIsActive,
|
||||||
|
onPlaybackStart,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
ref: React.Ref<{togglePlayback: () => void}>
|
ref: React.Ref<{togglePlayback: () => void}>
|
||||||
@@ -33,6 +35,7 @@ export function VideoEmbedInnerNative({
|
|||||||
setStatus: (status: 'playing' | 'paused') => void
|
setStatus: (status: 'playing' | 'paused') => void
|
||||||
setIsLoading: (isLoading: boolean) => void
|
setIsLoading: (isLoading: boolean) => void
|
||||||
setIsActive: (isActive: boolean) => void
|
setIsActive: (isActive: boolean) => void
|
||||||
|
onPlaybackStart: (autoplay: boolean) => void
|
||||||
/**
|
/**
|
||||||
* Called with the native error message before the component throws to the
|
* Called with the native error message before the component throws to the
|
||||||
* surrounding error boundary.
|
* surrounding error boundary.
|
||||||
@@ -46,6 +49,7 @@ export function VideoEmbedInnerNative({
|
|||||||
const [muted, setMuted] = useVideoMuteState()
|
const [muted, setMuted] = useVideoMuteState()
|
||||||
const reportDialogMetadata = useReportDialogMetadataContext()
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
const maxTimeRemainingSeconds = useRef(0)
|
const maxTimeRemainingSeconds = useRef(0)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(0)
|
const [timeRemaining, setTimeRemaining] = useState(0)
|
||||||
@@ -62,12 +66,13 @@ export function VideoEmbedInnerNative({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
|
const autoplay = !autoplayDisabled && !isWithinMessage
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_1, a.relative]}>
|
<View style={[a.flex_1, a.relative]}>
|
||||||
<BlueskyVideoView
|
<BlueskyVideoView
|
||||||
url={embed.playlist}
|
url={embed.playlist}
|
||||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
autoplay={autoplay}
|
||||||
beginMuted={isGif || (autoplayDisabled ? false : muted)}
|
beginMuted={isGif || (autoplayDisabled ? false : muted)}
|
||||||
style={[a.rounded_sm]}
|
style={[a.rounded_sm]}
|
||||||
onActiveChange={e => {
|
onActiveChange={e => {
|
||||||
@@ -88,20 +93,26 @@ export function VideoEmbedInnerNative({
|
|||||||
onTimeRemainingChange={e => {
|
onTimeRemainingChange={e => {
|
||||||
const {timeRemaining} = e.nativeEvent
|
const {timeRemaining} = e.nativeEvent
|
||||||
setTimeRemaining(timeRemaining)
|
setTimeRemaining(timeRemaining)
|
||||||
if (
|
if (Number.isFinite(timeRemaining) && timeRemaining >= 0) {
|
||||||
!isGif &&
|
|
||||||
reportDialogMetadata &&
|
|
||||||
Number.isFinite(timeRemaining) &&
|
|
||||||
timeRemaining >= 0
|
|
||||||
) {
|
|
||||||
maxTimeRemainingSeconds.current = Math.max(
|
maxTimeRemainingSeconds.current = Math.max(
|
||||||
maxTimeRemainingSeconds.current,
|
maxTimeRemainingSeconds.current,
|
||||||
timeRemaining,
|
timeRemaining,
|
||||||
)
|
)
|
||||||
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
|
if (
|
||||||
0,
|
!playbackStartTrackedRef.current &&
|
||||||
maxTimeRemainingSeconds.current - timeRemaining,
|
hasPlaybackStarted(
|
||||||
)
|
maxTimeRemainingSeconds.current - timeRemaining,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
playbackStartTrackedRef.current = true
|
||||||
|
onPlaybackStart(autoplay)
|
||||||
|
}
|
||||||
|
if (!isGif && reportDialogMetadata) {
|
||||||
|
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
|
||||||
|
0,
|
||||||
|
maxTimeRemainingSeconds.current - timeRemaining,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onError={e => {
|
onError={e => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type VideoEmbedInnerWebProps = {
|
|||||||
setActive: () => void
|
setActive: () => void
|
||||||
onScreen: boolean
|
onScreen: boolean
|
||||||
lastKnownTime: React.RefObject<number | undefined>
|
lastKnownTime: React.RefObject<number | undefined>
|
||||||
|
onPlaybackStart: (autoplay: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class HLSUnsupportedError extends Error {
|
export class HLSUnsupportedError extends Error {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react/macro'
|
|||||||
import type * as HlsTypes from 'hls.js'
|
import type * as HlsTypes from 'hls.js'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||||
@@ -29,6 +30,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
setActive,
|
setActive,
|
||||||
onScreen,
|
onScreen,
|
||||||
lastKnownTime,
|
lastKnownTime,
|
||||||
|
onPlaybackStart,
|
||||||
}: VideoEmbedInnerWebProps) {
|
}: VideoEmbedInnerWebProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
@@ -40,6 +42,7 @@ export function VideoEmbedInnerWeb({
|
|||||||
const [isFullscreen] = useFullscreen(containerRef)
|
const [isFullscreen] = useFullscreen(containerRef)
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
const reportDialogMetadata = useReportDialogMetadataContext()
|
const reportDialogMetadata = useReportDialogMetadataContext()
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
|
||||||
// send error up to error boundary
|
// send error up to error boundary
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
@@ -79,6 +82,13 @@ export function VideoEmbedInnerWeb({
|
|||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => {
|
||||||
const currentTime = e.currentTarget.currentTime
|
const currentTime = e.currentTarget.currentTime
|
||||||
lastKnownTime.current = currentTime
|
lastKnownTime.current = currentTime
|
||||||
|
if (
|
||||||
|
!playbackStartTrackedRef.current &&
|
||||||
|
hasPlaybackStarted(currentTime)
|
||||||
|
) {
|
||||||
|
playbackStartTrackedRef.current = true
|
||||||
|
onPlaybackStart(!focused)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!isGif &&
|
!isGif &&
|
||||||
reportDialogMetadata &&
|
reportDialogMetadata &&
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
embed: app.bsky.embed.video.View
|
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 [key, setKey] = useState(0)
|
||||||
|
|
||||||
const renderError = useCallback(
|
const renderError = useCallback(
|
||||||
@@ -52,7 +53,7 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
|
|
||||||
const contents = (
|
const contents = (
|
||||||
<ErrorBoundary renderError={renderError} key={key}>
|
<ErrorBoundary renderError={renderError} key={key}>
|
||||||
<InnerWrapper embed={embed} />
|
<InnerWrapper embed={embed} post={post} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerWrapper({embed}: Props) {
|
function InnerWrapper({embed, post}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const ref = useRef<{togglePlayback: () => void}>(null)
|
const ref = useRef<{togglePlayback: () => void}>(null)
|
||||||
@@ -86,6 +87,8 @@ function InnerWrapper({embed}: Props) {
|
|||||||
* the active position cost nothing.
|
* the active position cost nothing.
|
||||||
*/
|
*/
|
||||||
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
const telemetryRef = useRef<PlaybackTelemetry | null>(null)
|
||||||
|
const impressionTrackedRef = useRef(false)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
telemetryRef.current?.deactivated()
|
telemetryRef.current?.deactivated()
|
||||||
@@ -121,6 +124,15 @@ function InnerWrapper({embed}: Props) {
|
|||||||
setIsActive={active => {
|
setIsActive={active => {
|
||||||
setIsActive(active)
|
setIsActive(active)
|
||||||
if (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) {
|
if (telemetryRef.current == null) {
|
||||||
telemetryRef.current = createPlaybackTelemetry({
|
telemetryRef.current = createPlaybackTelemetry({
|
||||||
surface: 'feed',
|
surface: 'feed',
|
||||||
@@ -132,6 +144,17 @@ function InnerWrapper({embed}: Props) {
|
|||||||
telemetryRef.current?.deactivated()
|
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 => {
|
onError={error => {
|
||||||
telemetryRef.current?.error(error)
|
telemetryRef.current?.error(error)
|
||||||
ax.metric('video:playback:failed', {
|
ax.metric('video:playback:failed', {
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ const noop = () => {}
|
|||||||
*/
|
*/
|
||||||
const MIN_CARD_WIDTH = 280
|
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 t = useTheme()
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const {
|
const {
|
||||||
@@ -47,13 +53,28 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
currentActiveView,
|
currentActiveView,
|
||||||
} = useActiveVideoWeb()
|
} = useActiveVideoWeb()
|
||||||
const [onScreen, setOnScreen] = useState(false)
|
const [onScreen, setOnScreen] = useState(false)
|
||||||
|
const [meaningfullyVisible, setMeaningfullyVisible] = useState(false)
|
||||||
const [isFullscreen] = useFullscreen()
|
const [isFullscreen] = useFullscreen()
|
||||||
const lastKnownTime = useRef<number | undefined>(undefined)
|
const lastKnownTime = useRef<number | undefined>(undefined)
|
||||||
|
const impressionTrackedRef = useRef(false)
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
const ax = useAnalytics()
|
||||||
|
|
||||||
const isGif = embed.presentation === 'gif'
|
const isGif = embed.presentation === 'gif'
|
||||||
// GIFs don't participate in the "one video at a time" system
|
// GIFs don't participate in the "one video at a time" system
|
||||||
const active = isGif || activeFromContext
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
if (isFullscreen && !IS_WEB_FIREFOX) 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]
|
const entry = entries[0]
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
setOnScreen(entry.isIntersecting)
|
setOnScreen(entry.isIntersecting)
|
||||||
|
setMeaningfullyVisible(
|
||||||
|
entry.isIntersecting && entry.intersectionRatio >= 0.5,
|
||||||
|
)
|
||||||
// GIFs don't send position - they don't compete to be the active video
|
// GIFs don't send position - they don't compete to be the active video
|
||||||
if (!isGif) {
|
if (!isGif) {
|
||||||
sendPosition(
|
sendPosition(
|
||||||
@@ -179,6 +203,17 @@ export function VideoEmbed({embed}: {embed: app.bsky.embed.video.View}) {
|
|||||||
setActive={setActive}
|
setActive={setActive}
|
||||||
onScreen={onScreen}
|
onScreen={onScreen}
|
||||||
lastKnownTime={lastKnownTime}
|
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>
|
</OnlyNearScreen>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ function MediaEmbed({
|
|||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={embed.view.external}
|
link={embed.view.external}
|
||||||
onOpen={rest.onOpen}
|
onOpen={rest.onOpen}
|
||||||
|
post={rest.post}
|
||||||
style={[a.mt_sm, rest.style]}
|
style={[a.mt_sm, rest.style]}
|
||||||
/>
|
/>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
@@ -144,7 +145,7 @@ function MediaEmbed({
|
|||||||
<ContentHider
|
<ContentHider
|
||||||
modui={rest.moderation?.ui('contentMedia')}
|
modui={rest.moderation?.ui('contentMedia')}
|
||||||
activeStyle={[a.mt_sm]}>
|
activeStyle={[a.mt_sm]}>
|
||||||
<VideoEmbed embed={embed.view} />
|
<VideoEmbed embed={embed.view} post={rest.post} />
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useMemo} from 'react'
|
|||||||
import {type StyleProp, type TextStyle} from 'react-native'
|
import {type StyleProp, type TextStyle} from 'react-native'
|
||||||
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
|
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
|
||||||
|
|
||||||
|
import {isRTLText} from '#/lib/strings/text-direction'
|
||||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||||
import {android, atoms as a, flatten, type TextStyleProp} from '#/alf'
|
import {android, atoms as a, flatten, type TextStyleProp} from '#/alf'
|
||||||
import {isOnlyEmoji} from '#/alf/typography'
|
import {isOnlyEmoji} from '#/alf/typography'
|
||||||
@@ -9,6 +10,7 @@ import {InlineLinkText, type LinkProps} from '#/components/Link'
|
|||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichTextTag} from '#/components/RichTextTag'
|
import {RichTextTag} from '#/components/RichTextTag'
|
||||||
import {Text, type TextProps} from '#/components/Typography'
|
import {Text, type TextProps} from '#/components/Typography'
|
||||||
|
import {IS_NATIVE} from '#/env'
|
||||||
import {app} from '#/lexicons'
|
import {app} from '#/lexicons'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
@@ -82,15 +84,17 @@ export function RichText({
|
|||||||
}
|
}
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
const plainStyles = style
|
const {text, facets} = richText
|
||||||
|
const plainStyles: StyleProp<TextStyle> = [
|
||||||
|
style,
|
||||||
|
IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null,
|
||||||
|
]
|
||||||
const suffixStyles =
|
const suffixStyles =
|
||||||
suffix && suffixOffset
|
suffix && suffixOffset
|
||||||
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
|
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
|
||||||
: null
|
: null
|
||||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||||
|
|
||||||
const {text, facets} = richText
|
|
||||||
|
|
||||||
if (!facets?.length) {
|
if (!facets?.length) {
|
||||||
if (isOnlyEmoji(text)) {
|
if (isOnlyEmoji(text)) {
|
||||||
const flattenedStyle = flatten(style)
|
const flattenedStyle = flatten(style)
|
||||||
|
|||||||
@@ -53,14 +53,15 @@ function ShareDialogInner({
|
|||||||
|
|
||||||
const imageUrl = getStarterPackOgCard(starterPack)
|
const imageUrl = getStarterPackOgCard(starterPack)
|
||||||
|
|
||||||
const onShareLink = async () => {
|
const onShareLink = () => {
|
||||||
if (!link) return
|
if (!link) return
|
||||||
shareUrl(link)
|
|
||||||
ax.metric('starterPack:share', {
|
ax.metric('starterPack:share', {
|
||||||
starterPack: starterPack.uri,
|
starterPack: starterPack.uri,
|
||||||
shareType: 'link',
|
shareType: 'link',
|
||||||
})
|
})
|
||||||
control.close()
|
control.close(() => {
|
||||||
|
void shareUrl(link)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveImageToAlbum = useSaveImageToMediaLibrary()
|
const saveImageToAlbum = useSaveImageToMediaLibrary()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
import {EMBED_SCRIPT} from '#/lib/constants'
|
import {EMBED_SCRIPT} from '#/lib/constants'
|
||||||
import {niceDate} from '#/lib/strings/time'
|
import {niceDate} from '#/lib/strings/time'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
@@ -103,7 +103,7 @@ function EmbedDialogInner({
|
|||||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
|
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
|
||||||
|
|
||||||
return (
|
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_lg]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<Text style={[a.text_2xl, a.font_bold]}>
|
<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 {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {Logo} from '#/view/icons/Logo'
|
import {Logo} from '#/view/icons/Logo'
|
||||||
import {Logotype} from '#/view/icons/Logotype'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
@@ -45,7 +45,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
|||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Sign in to Bluesky or create a new account`)}
|
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={[!IS_NATIVE && a.p_2xl]}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {urls} from '#/lib/constants'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||||
@@ -38,7 +38,8 @@ export function InitialVerificationAnnouncement() {
|
|||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Announcing verification on Bluesky`)}
|
label={_(msg`Announcing verification on Bluesky`)}
|
||||||
style={[
|
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 style={[a.align_start, a.gap_xl]}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -116,14 +116,15 @@ function Inner({
|
|||||||
setActiveNux(undefined)
|
setActiveNux(undefined)
|
||||||
}, [activeNux, setActiveNux])
|
}, [activeNux, setActiveNux])
|
||||||
|
|
||||||
if (__DEV__ && typeof window !== 'undefined') {
|
useEffect(() => {
|
||||||
// @ts-expect-error
|
if (!__DEV__ || typeof window === 'undefined') return
|
||||||
|
// @ts-expect-error debug only
|
||||||
window.clearNuxDialog = (id: Nux) => {
|
window.clearNuxDialog = (id: Nux) => {
|
||||||
if (!__DEV__ || !id) return
|
if (!id) return
|
||||||
resetNuxs([id])
|
resetNuxs([id])
|
||||||
unsnooze()
|
unsnooze()
|
||||||
}
|
}
|
||||||
}
|
}, [resetNuxs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (snoozed) return // comment this out to test
|
if (snoozed) return // comment this out to test
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {usePdsClient, useSession} from '#/state/session'
|
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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {type DialogControlProps} from '#/components/Dialog'
|
import {type DialogControlProps} from '#/components/Dialog'
|
||||||
@@ -62,7 +62,8 @@ function Inner({}: {control: DialogControlProps}) {
|
|||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Verify email dialog`)}
|
label={_(msg`Verify email dialog`)}
|
||||||
style={[
|
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]}>
|
<View style={[a.gap_xl]}>
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
|
|||||||
@@ -101,35 +101,28 @@ function ContentHiderActive({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selfBlurCauses = []
|
||||||
let hasAdultContentLabel = false
|
let hasAdultContentLabel = false
|
||||||
const selfBlurNames = modui.blurs
|
for (const cause of modui.blurs) {
|
||||||
.filter(cause => {
|
if (cause.type !== 'label') continue
|
||||||
if (cause.type !== 'label') {
|
if (cause.source.type !== 'user') continue
|
||||||
return false
|
if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
|
||||||
}
|
if (hasAdultContentLabel) continue
|
||||||
if (cause.source.type !== 'user') {
|
hasAdultContentLabel = true
|
||||||
return false
|
}
|
||||||
}
|
selfBlurCauses.push(cause)
|
||||||
if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
|
}
|
||||||
if (hasAdultContentLabel) {
|
const selfBlurNames = selfBlurCauses.slice(0, 2).map(cause => {
|
||||||
return false
|
if (cause.type !== 'label') {
|
||||||
}
|
return
|
||||||
hasAdultContentLabel = true
|
}
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(cause => {
|
|
||||||
if (cause.type !== 'label') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||||
return l`Adult Content`
|
return l`Adult Content`
|
||||||
}
|
}
|
||||||
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
|
return getLabelStrings(i18n.locale, globalLabelStrings, def).name
|
||||||
})
|
})
|
||||||
|
|
||||||
if (selfBlurNames.length === 0) {
|
if (selfBlurNames.length === 0) {
|
||||||
return desc.name
|
return desc.name
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
|
|||||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
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 * as Admonition from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
@@ -334,7 +334,7 @@ function Inner(
|
|||||||
testID="report:dialog"
|
testID="report:dialog"
|
||||||
label={l`Report dialog`}
|
label={l`Report dialog`}
|
||||||
ref={ref}
|
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]}>
|
<View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}>
|
||||||
<StepOuter>
|
<StepOuter>
|
||||||
<StepTitle
|
<StepTitle
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
|||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
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 {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
@@ -78,7 +78,8 @@ function Inner({
|
|||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={label}
|
label={label}
|
||||||
style={[
|
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]}>
|
<View style={[a.gap_sm, a.pb_lg]}>
|
||||||
<Text style={[a.text_2xl, a.font_semi_bold, a.pr_4xl, a.leading_tight]}>
|
<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 {urls} from '#/lib/constants'
|
||||||
import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
||||||
import {useSession} from '#/state/session'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
||||||
@@ -65,7 +65,8 @@ function Inner({
|
|||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={label}
|
label={label}
|
||||||
style={[
|
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 style={[a.gap_lg]}>
|
||||||
<View
|
<View
|
||||||
|
|||||||
Vendored
+8
@@ -1,2 +1,10 @@
|
|||||||
// TS6.0 enables noUncheckedSideEffectImports
|
// TS6.0 enables noUncheckedSideEffectImports
|
||||||
declare module '*.css'
|
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'
|
'opThreadPostIndex' | 'opThreadPostCount'
|
||||||
>
|
>
|
||||||
|
|
||||||
type ValidFeedPostNumbering = Required<FeedPostNumbering>
|
export type ValidFeedPostNumbering = Required<FeedPostNumbering>
|
||||||
|
|
||||||
// AppView adds these fields to feed responses ahead of their feed lexicon.
|
// AppView adds these fields to feed responses ahead of their feed lexicon.
|
||||||
type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering
|
type FeedViewPost = app.bsky.feed.defs.FeedViewPost & FeedPostNumbering
|
||||||
@@ -62,7 +62,7 @@ export type FeedTunerFn = (
|
|||||||
type FeedSliceItem = {
|
type FeedSliceItem = {
|
||||||
post: app.bsky.feed.defs.PostView
|
post: app.bsky.feed.defs.PostView
|
||||||
record: app.bsky.feed.post.Main
|
record: app.bsky.feed.post.Main
|
||||||
postNumbering: FeedPostNumbering | undefined
|
postNumbering: ValidFeedPostNumbering | undefined
|
||||||
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
|
parentAuthor: app.bsky.actor.defs.ProfileViewBasic | undefined
|
||||||
isParentBlocked: boolean
|
isParentBlocked: boolean
|
||||||
isParentNotFound: boolean
|
isParentNotFound: boolean
|
||||||
@@ -88,7 +88,7 @@ export class FeedViewPostsSlice {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
feedPost: FeedViewPost,
|
feedPost: FeedViewPost,
|
||||||
postNumberingByUri: Map<string, FeedPostNumbering>,
|
postNumberingByUri: Map<string, ValidFeedPostNumbering>,
|
||||||
) {
|
) {
|
||||||
const {post, reply, reason} = feedPost
|
const {post, reply, reason} = feedPost
|
||||||
this.items = []
|
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 {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
@@ -299,17 +315,7 @@ export class FeedTuner {
|
|||||||
dryRun: false,
|
dryRun: false,
|
||||||
},
|
},
|
||||||
): FeedViewPostsSlice[] {
|
): FeedViewPostsSlice[] {
|
||||||
const postNumberingByUri = new Map<string, FeedPostNumbering>()
|
let slices = createFeedViewPostsSlices(feed)
|
||||||
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)
|
|
||||||
|
|
||||||
// run the custom tuners
|
// run the custom tuners
|
||||||
for (const tunerFn of this.tunerFns) {
|
for (const tunerFn of this.tunerFns) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function usePostViewTracking(
|
|||||||
ax.metric('post:view', {
|
ax.metric('post:view', {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
authorDid: post.author.did,
|
authorDid: post.author.did,
|
||||||
|
isReply: !!post.record.reply,
|
||||||
logContext,
|
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_album'
|
||||||
| 'bandcamp_track'
|
| '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> = {
|
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||||
youtube: 'YouTube',
|
youtube: 'YouTube',
|
||||||
youtubeShorts: 'YouTube Shorts',
|
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}
|
) : null}
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
</Layout.Center>
|
</Layout.Center>
|
||||||
<Dialog.Outer control={infoControl}>
|
<Dialog.Outer
|
||||||
|
control={infoControl}
|
||||||
|
nativeOptions={{preventExpansion: true}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={l`Feed menu`}
|
label={l`Feed menu`}
|
||||||
style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
|
style={[a.w_full, gtMobile && web({width: 'auto', minWidth: 450})]}>
|
||||||
<DialogInner
|
<DialogInner
|
||||||
info={info}
|
info={info}
|
||||||
likeUri={likeUri}
|
likeUri={likeUri}
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ export function StepProfile() {
|
|||||||
{
|
{
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
maxWidth: 410,
|
maxWidth: 410,
|
||||||
|
marginHorizontal: 'auto',
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.align_center, {paddingTop: 20}]}>
|
<View style={[a.align_center, {paddingTop: 20}]}>
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export function PostThread({uri}: {uri: string}) {
|
|||||||
ax.metric('post:view', {
|
ax.metric('post:view', {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
authorDid: post.author.did,
|
authorDid: post.author.did,
|
||||||
|
isReply: !!post.record.reply,
|
||||||
logContext: 'Post',
|
logContext: 'Post',
|
||||||
feedDescriptor: feedFeedback.feedDescriptor,
|
feedDescriptor: feedFeedback.feedDescriptor,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {HITSLOP_20} from '#/lib/constants'
|
|||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||||
|
import {hasPlaybackStarted} from '#/lib/media/video/analytics'
|
||||||
import {
|
import {
|
||||||
createPlaybackTelemetry,
|
createPlaybackTelemetry,
|
||||||
type PlaybackTelemetry,
|
type PlaybackTelemetry,
|
||||||
@@ -492,9 +493,19 @@ let VideoItem = ({
|
|||||||
const {width, height} = useSafeAreaFrame()
|
const {width, height} = useSafeAreaFrame()
|
||||||
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
|
const {sendInteraction, feedDescriptor} = useFeedFeedbackContext()
|
||||||
const hasTrackedView = useRef(false)
|
const hasTrackedView = useRef(false)
|
||||||
|
const hasTrackedVideoImpression = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) {
|
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({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionSeen',
|
event: 'app.bsky.feed.defs#interactionSeen',
|
||||||
@@ -508,6 +519,7 @@ let VideoItem = ({
|
|||||||
ax.metric('post:view', {
|
ax.metric('post:view', {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
authorDid: post.author.did,
|
authorDid: post.author.did,
|
||||||
|
isReply: !!post.record.reply,
|
||||||
logContext: 'ImmersiveVideo',
|
logContext: 'ImmersiveVideo',
|
||||||
feedDescriptor,
|
feedDescriptor,
|
||||||
})
|
})
|
||||||
@@ -518,6 +530,7 @@ let VideoItem = ({
|
|||||||
active,
|
active,
|
||||||
post.uri,
|
post.uri,
|
||||||
post.author.did,
|
post.author.did,
|
||||||
|
embed.presentation,
|
||||||
feedContext,
|
feedContext,
|
||||||
reqId,
|
reqId,
|
||||||
sendInteraction,
|
sendInteraction,
|
||||||
@@ -556,7 +569,12 @@ let VideoItem = ({
|
|||||||
<>
|
<>
|
||||||
<VideoItemPlaceholder embed={embed} />
|
<VideoItemPlaceholder embed={embed} />
|
||||||
{shouldRenderVideo && player && (
|
{shouldRenderVideo && player && (
|
||||||
<VideoItemInner player={player} embed={embed} active={active} />
|
<VideoItemInner
|
||||||
|
player={player}
|
||||||
|
embed={embed}
|
||||||
|
post={post}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{moderation && (
|
{moderation && (
|
||||||
<Overlay
|
<Overlay
|
||||||
@@ -586,16 +604,20 @@ VideoItem = memo(VideoItem)
|
|||||||
function VideoItemInner({
|
function VideoItemInner({
|
||||||
player,
|
player,
|
||||||
embed,
|
embed,
|
||||||
|
post,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
player: VideoPlayer
|
player: VideoPlayer
|
||||||
embed: app.bsky.embed.video.View
|
embed: app.bsky.embed.video.View
|
||||||
|
post: app.bsky.feed.defs.PostView
|
||||||
active: boolean
|
active: boolean
|
||||||
}) {
|
}) {
|
||||||
const {bottom} = useSafeAreaInsets()
|
const {bottom} = useSafeAreaInsets()
|
||||||
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
const [isReady, setIsReady] = useState(!IS_ANDROID)
|
||||||
const reportDialogMetadata =
|
const reportDialogMetadata =
|
||||||
ReportDialogMetadataContext.useReportDialogMetadataContext()
|
ReportDialogMetadataContext.useReportDialogMetadataContext()
|
||||||
|
const ax = useAnalytics()
|
||||||
|
const playbackStartTrackedRef = useRef(false)
|
||||||
|
|
||||||
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
usePlaybackTelemetry({player, active, playlist: embed.playlist})
|
||||||
|
|
||||||
@@ -616,6 +638,20 @@ function VideoItemInner({
|
|||||||
) {
|
) {
|
||||||
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime
|
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 (
|
return (
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
|
|
||||||
import {CustomFeedAPI} from '#/lib/api/feed/custom'
|
import {CustomFeedAPI} from '#/lib/api/feed/custom'
|
||||||
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
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 {cleanError} from '#/lib/strings/errors'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {
|
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(
|
export function* findAllProfilesInQueryData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
did: string,
|
did: string,
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import {PostListFeedAPI} from '#/lib/api/feed/posts'
|
|||||||
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
|
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
|
||||||
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||||
import {
|
import {
|
||||||
|
createFeedViewPostsSlices,
|
||||||
type FeedPostNumbering,
|
type FeedPostNumbering,
|
||||||
FeedTuner,
|
FeedTuner,
|
||||||
type FeedTunerFn,
|
type FeedTunerFn,
|
||||||
|
type ValidFeedPostNumbering,
|
||||||
} from '#/lib/api/feed-manip'
|
} from '#/lib/api/feed-manip'
|
||||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
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(
|
export function* findAllProfilesInQueryData(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
did: string,
|
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 {AtUri} from '@atproto/syntax'
|
||||||
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
|
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip'
|
||||||
import {
|
import {
|
||||||
dangerousGetPostShadow,
|
dangerousGetPostShadow,
|
||||||
updatePostShadow,
|
updatePostShadow,
|
||||||
} from '#/state/cache/post-shadow'
|
} from '#/state/cache/post-shadow'
|
||||||
import {findAllPostsInQueryData as findAllPostsInBookmarksQueryData} from '#/state/queries/bookmarks/useBookmarksQuery'
|
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 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 findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2'
|
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts-v2'
|
||||||
import {usePostThreadContext} from '#/state/queries/usePostThread'
|
import {usePostThreadContext} from '#/state/queries/usePostThread'
|
||||||
@@ -207,8 +214,15 @@ export function getThreadPlaceholder(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
uri: string,
|
uri: string,
|
||||||
): $Typed<app.bsky.unspecced.getPostThreadV2.ThreadItem> | void {
|
): $Typed<app.bsky.unspecced.getPostThreadV2.ThreadItem> | void {
|
||||||
|
const postNumbering =
|
||||||
|
findPostNumberingInFeedQueryData(queryClient, uri) ??
|
||||||
|
findPostNumberingInExploreFeedPreviewsQueryData(queryClient, uri)
|
||||||
let partial
|
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
|
* 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
|
* example, for quoted posts). We use missing `likeCount` as a way to
|
||||||
@@ -231,6 +245,7 @@ export function getThreadPlaceholder(
|
|||||||
export function* getThreadPlaceholderCandidates(
|
export function* getThreadPlaceholderCandidates(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
uri: string,
|
uri: string,
|
||||||
|
postNumbering?: ValidFeedPostNumbering,
|
||||||
): Generator<
|
): Generator<
|
||||||
$Typed<
|
$Typed<
|
||||||
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
|
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
|
||||||
@@ -243,7 +258,7 @@ export function* getThreadPlaceholderCandidates(
|
|||||||
* Check post thread queries first
|
* Check post thread queries first
|
||||||
*/
|
*/
|
||||||
for (const post of findAllPostsInQueryData(queryClient, uri)) {
|
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.
|
* avoid a notification->post scroll jump.
|
||||||
*/
|
*/
|
||||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInBookmarksQueryData(queryClient, uri)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
||||||
queryClient,
|
queryClient,
|
||||||
uri,
|
uri,
|
||||||
)) {
|
)) {
|
||||||
yield postViewToThreadPlaceholder(post)
|
yield postViewToThreadPlaceholder(post, postNumbering)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {type $Typed} from '@atproto/lex'
|
|||||||
import {AtUri} from '@atproto/syntax'
|
import {AtUri} from '@atproto/syntax'
|
||||||
import {moderatePost, type ModerationOpts} from '@bsky/sdk/moderation'
|
import {moderatePost, type ModerationOpts} from '@bsky/sdk/moderation'
|
||||||
|
|
||||||
|
import {type ValidFeedPostNumbering} from '#/lib/api/feed-manip'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {
|
import {
|
||||||
type ApiThreadItem,
|
type ApiThreadItem,
|
||||||
@@ -156,6 +157,7 @@ export function skeleton({
|
|||||||
|
|
||||||
export function postViewToThreadPlaceholder(
|
export function postViewToThreadPlaceholder(
|
||||||
post: app.bsky.feed.defs.PostView,
|
post: app.bsky.feed.defs.PostView,
|
||||||
|
postNumbering?: ValidFeedPostNumbering,
|
||||||
): $Typed<
|
): $Typed<
|
||||||
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
|
Omit<app.bsky.unspecced.getPostThreadV2.ThreadItem, 'value'> & {
|
||||||
value: $Typed<app.bsky.unspecced.defs.ThreadItemPost>
|
value: $Typed<app.bsky.unspecced.defs.ThreadItemPost>
|
||||||
@@ -168,7 +170,8 @@ export function postViewToThreadPlaceholder(
|
|||||||
value: {
|
value: {
|
||||||
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
$type: 'app.bsky.unspecced.defs#threadItemPost',
|
||||||
post,
|
post,
|
||||||
opThread: false,
|
opThread: !!postNumbering,
|
||||||
|
...postNumbering,
|
||||||
moreParents: false,
|
moreParents: false,
|
||||||
moreReplies: 0,
|
moreReplies: 0,
|
||||||
hiddenByThreadgate: false,
|
hiddenByThreadgate: false,
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ describe('useAutoPagination', () => {
|
|||||||
const itemCount = 0
|
const itemCount = 0
|
||||||
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
const {rerender} = renderHook(() => useAutoPagination(value, itemCount, 10))
|
||||||
|
|
||||||
for (let i = 1; i < 50; i++) {
|
for (let i = 1; i < 5; i++) {
|
||||||
value = query({
|
value = query({
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
rerender(undefined)
|
rerender(undefined)
|
||||||
}
|
}
|
||||||
expect(fetchNextPage).toHaveBeenCalledTimes(49)
|
expect(fetchNextPage).toHaveBeenCalledTimes(4)
|
||||||
|
|
||||||
const second = query({
|
const second = query({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
import {app} from '#/lexicons'
|
import {app} from '#/lexicons'
|
||||||
import * as bsky from '#/types/bsky'
|
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 = {
|
type AutoPaginationQuery = {
|
||||||
data?: {pageParams: unknown[]}
|
data?: {pageParams: unknown[]}
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -75,7 +83,7 @@ export function useAutoPagination(
|
|||||||
.some(param => Object.is(cursorOf(param), currentCursor))
|
.some(param => Object.is(cursorOf(param), currentCursor))
|
||||||
if (repeatedCursor) return
|
if (repeatedCursor) return
|
||||||
attemptCount.current++
|
attemptCount.current++
|
||||||
if (attemptCount.current < 50) {
|
if (attemptCount.current < MAX_ATTEMPTS) {
|
||||||
void query.fetchNextPage()
|
void query.fetchNextPage()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -683,9 +683,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const bundle = state.currentBundleState.bundle as unknown as
|
const bundle = state.currentBundleState.bundle as unknown as
|
||||||
SessionBundle | PublicSessionBundle
|
SessionBundle | PublicSessionBundle
|
||||||
|
|
||||||
// @ts-expect-error window type is not declared, debug only
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/immutability
|
if (!__DEV__ || !IS_WEB) return
|
||||||
if (__DEV__ && IS_WEB) window.bundle = bundle
|
// @ts-expect-error window type is not declared, debug only
|
||||||
|
window.bundle = bundle
|
||||||
|
}, [bundle])
|
||||||
|
|
||||||
const currentBundleRef = useRef(bundle)
|
const currentBundleRef = useRef(bundle)
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1555,12 +1555,12 @@ export const ComposePost = ({
|
|||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Prompt.Cancel cta={l`Keep editing`} />
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
cta={l`Discard`}
|
cta={l`Discard`}
|
||||||
onPress={handleDiscard}
|
onPress={handleDiscard}
|
||||||
color="negative_subtle"
|
color="negative_subtle"
|
||||||
/>
|
/>
|
||||||
<Prompt.Cancel cta={l`Keep editing`} />
|
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
)}
|
)}
|
||||||
@@ -1681,6 +1681,8 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
// Keep focused inputs attached while active-state opacity changes.
|
||||||
|
collapsable={false}
|
||||||
style={[
|
style={[
|
||||||
a.mx_lg,
|
a.mx_lg,
|
||||||
a.mb_sm,
|
a.mb_sm,
|
||||||
@@ -1700,7 +1702,7 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
style={[a.pt_xs]}
|
style={[a.pt_xs]}
|
||||||
richtext={richtext}
|
richtext={richtext}
|
||||||
placeholder={selectTextInputPlaceholder}
|
placeholder={selectTextInputPlaceholder}
|
||||||
autoFocus={isLastPost}
|
autoFocus={isActive}
|
||||||
webForceMinHeight={forceMinHeight}
|
webForceMinHeight={forceMinHeight}
|
||||||
// To avoid overlap with the close button:
|
// To avoid overlap with the close button:
|
||||||
hasRightPadding={isPartOfThread}
|
hasRightPadding={isPartOfThread}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
@@ -30,7 +28,7 @@ export function DraftsButton({
|
|||||||
canSaveDraft: boolean
|
canSaveDraft: boolean
|
||||||
textLength: number
|
textLength: number
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const draftsDialogControl = Dialog.useDialogControl()
|
const draftsDialogControl = Dialog.useDialogControl()
|
||||||
const savePromptControl = Prompt.usePromptControl()
|
const savePromptControl = Prompt.usePromptControl()
|
||||||
@@ -67,7 +65,7 @@ export function DraftsButton({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Drafts`)}
|
label={l`Drafts`}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="primary"
|
color="primary"
|
||||||
shape="default"
|
shape="default"
|
||||||
@@ -79,12 +77,10 @@ export function DraftsButton({
|
|||||||
<Trans>Drafts</Trans>
|
<Trans>Drafts</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DraftsListDialog
|
<DraftsListDialog
|
||||||
control={draftsDialogControl}
|
control={draftsDialogControl}
|
||||||
onSelectDraft={onSelectDraft}
|
onSelectDraft={onSelectDraft}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Prompt.Outer control={savePromptControl}>
|
<Prompt.Outer control={savePromptControl}>
|
||||||
<Prompt.Content>
|
<Prompt.Content>
|
||||||
<Prompt.TitleText>
|
<Prompt.TitleText>
|
||||||
@@ -122,17 +118,17 @@ export function DraftsButton({
|
|||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
{canSaveDraft && (
|
{canSaveDraft && (
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
cta={isEditingDraft ? _(msg`Save changes`) : _(msg`Save draft`)}
|
cta={isEditingDraft ? l`Save changes` : l`Save draft`}
|
||||||
onPress={handleSaveAndOpen}
|
onPress={() => void handleSaveAndOpen()}
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Prompt.Cancel cta={l`Keep editing`} />
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
cta={_(msg`Discard`)}
|
cta={l`Discard`}
|
||||||
onPress={handleDiscardAndOpen}
|
onPress={handleDiscardAndOpen}
|
||||||
color="negative_subtle"
|
color="negative_subtle"
|
||||||
/>
|
/>
|
||||||
<Prompt.Cancel cta={_(msg`Keep editing`)} />
|
|
||||||
</Prompt.Actions>
|
</Prompt.Actions>
|
||||||
</Prompt.Outer>
|
</Prompt.Outer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function DialogInner({
|
|||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner
|
||||||
label={_(msg`Add a content warning`)}
|
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.flex_1]}>
|
||||||
<View style={[a.gap_sm]}>
|
<View style={[a.gap_sm]}>
|
||||||
<Text style={[a.text_2xl, a.font_semi_bold]}>
|
<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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
|
activePostIndex: activePostIndex + 1,
|
||||||
|
mutableNeedsFocusActive: true,
|
||||||
thread: {
|
thread: {
|
||||||
...state.thread,
|
...state.thread,
|
||||||
posts: nextPosts,
|
posts: nextPosts,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {useCallback, useMemo} from 'react'
|
import {useCallback, useMemo} from 'react'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {TIMELINE_SAVED_FEED} from '#/lib/constants'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
|
||||||
import {type FeedSourceInfo} from '#/state/queries/feed'
|
import {type FeedSourceInfo} from '#/state/queries/feed'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {type RenderTabBarFnProps} from '#/view/com/pager/Pager'
|
import {type RenderTabBarFnProps} from '#/view/com/pager/Pager'
|
||||||
@@ -12,28 +15,29 @@ export function HomeHeader(
|
|||||||
props: RenderTabBarFnProps & {
|
props: RenderTabBarFnProps & {
|
||||||
testID?: string
|
testID?: string
|
||||||
onPressSelected: () => void
|
onPressSelected: () => void
|
||||||
feeds: FeedSourceInfo[]
|
feeds: Pick<FeedSourceInfo, 'displayName' | 'uri'>[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const {feeds, onSelect: onSelectProp} = props
|
const {feeds, onSelect: onSelectProp} = props
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
|
const {t: l, i18n} = useLingui()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const hasPinnedCustom = useMemo<boolean>(() => {
|
const hasPinnedCustom = useMemo<boolean>(() => {
|
||||||
if (!hasSession) return false
|
if (!hasSession) return false
|
||||||
return feeds.some(tab => {
|
return feeds.some(tab => {
|
||||||
const isFollowing = tab.uri === 'following'
|
const isFollowing = tab.uri === TIMELINE_SAVED_FEED.value
|
||||||
return !isFollowing
|
return !isFollowing
|
||||||
})
|
})
|
||||||
}, [feeds, hasSession])
|
}, [feeds, hasSession])
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const pinnedNames = feeds.map(f => f.displayName)
|
const pinnedNames = feeds.map(f => getLocalizedFeedName(f, i18n))
|
||||||
if (!hasPinnedCustom) {
|
if (!hasPinnedCustom) {
|
||||||
return pinnedNames.concat('Feeds ✨')
|
return pinnedNames.concat(l`Feeds ✨`)
|
||||||
}
|
}
|
||||||
return pinnedNames
|
return pinnedNames
|
||||||
}, [hasPinnedCustom, feeds])
|
}, [i18n, l, hasPinnedCustom, feeds])
|
||||||
|
|
||||||
const onPressFeedsLink = useCallback(() => {
|
const onPressFeedsLink = useCallback(() => {
|
||||||
navigation.navigate('Feeds')
|
navigation.navigate('Feeds')
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function TabBar({
|
|||||||
syncScrollState.set('unsynced')
|
syncScrollState.set('unsynced')
|
||||||
}}
|
}}
|
||||||
onScroll={e => {
|
onScroll={e => {
|
||||||
scrollX.value = Math.round(e.nativeEvent.contentOffset.x)
|
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
||||||
}}>
|
}}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
onLayout={e => {
|
onLayout={e => {
|
||||||
|
|||||||
@@ -1077,15 +1077,22 @@ let PostFeed = ({
|
|||||||
|
|
||||||
onPostSeen(post)
|
onPostSeen(post)
|
||||||
|
|
||||||
// Only track the root post of each slice (index 0) to avoid double-counting thread items
|
// Track the post selected by the feed once it is actually visible.
|
||||||
if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
|
if (
|
||||||
|
post.uri === slice.feedPostUri &&
|
||||||
|
!seenPostUrisRef.current.has(post.uri)
|
||||||
|
) {
|
||||||
seenPostUrisRef.current.add(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', {
|
ax.metric('post:view', {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
authorDid: post.author.did,
|
authorDid: post.author.did,
|
||||||
|
isReply: !!postItem.record.reply,
|
||||||
logContext: 'FeedItem',
|
logContext: 'FeedItem',
|
||||||
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
||||||
position,
|
position,
|
||||||
@@ -1121,6 +1128,7 @@ let PostFeed = ({
|
|||||||
ax.metric('post:view', {
|
ax.metric('post:view', {
|
||||||
uri: post.uri,
|
uri: post.uri,
|
||||||
authorDid: post.author.did,
|
authorDid: post.author.did,
|
||||||
|
isReply: !!postItem.record.reply,
|
||||||
logContext: 'FeedItem',
|
logContext: 'FeedItem',
|
||||||
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
||||||
position,
|
position,
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import {
|
|||||||
Reanimated3DefaultSpringConfig,
|
Reanimated3DefaultSpringConfig,
|
||||||
withSpring,
|
withSpring,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
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 {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates'
|
import {useOTAUpdates} from '#/lib/hooks/useOTAUpdates'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
@@ -15,6 +20,7 @@ import {
|
|||||||
type HomeTabNavigatorParams,
|
type HomeTabNavigatorParams,
|
||||||
type NativeStackScreenProps,
|
type NativeStackScreenProps,
|
||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
|
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
type SavedFeedSourceInfo,
|
type SavedFeedSourceInfo,
|
||||||
@@ -114,6 +120,7 @@ function HomeScreenReady({
|
|||||||
preferences: UsePreferencesQueryResponse
|
preferences: UsePreferencesQueryResponse
|
||||||
pinnedFeedInfos: SavedFeedSourceInfo[]
|
pinnedFeedInfos: SavedFeedSourceInfo[]
|
||||||
}) {
|
}) {
|
||||||
|
const {i18n} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const allFeeds = useMemo(
|
const allFeeds = useMemo(
|
||||||
() => pinnedFeedInfos.map(f => f.feedDescriptor),
|
() => pinnedFeedInfos.map(f => f.feedDescriptor),
|
||||||
@@ -125,13 +132,14 @@ function HomeScreenReady({
|
|||||||
const maybeFoundIndex = allFeeds.indexOf(maybeRawSelectedFeed)
|
const maybeFoundIndex = allFeeds.indexOf(maybeRawSelectedFeed)
|
||||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||||
const maybeSelectedFeed: FeedDescriptor | undefined = allFeeds[selectedIndex]
|
const maybeSelectedFeed: FeedDescriptor | undefined = allFeeds[selectedIndex]
|
||||||
|
const selectedFeedInfo = pinnedFeedInfos[selectedIndex]
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
|
|
||||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
useSetTitle(selectedFeedInfo && getLocalizedFeedName(selectedFeedInfo, i18n))
|
||||||
useOTAUpdates()
|
useOTAUpdates()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestNotificationsPermission('Home')
|
void requestNotificationsPermission('Home')
|
||||||
}, [requestNotificationsPermission])
|
}, [requestNotificationsPermission])
|
||||||
|
|
||||||
const pagerRef = useRef<PagerRef>(null)
|
const pagerRef = useRef<PagerRef>(null)
|
||||||
@@ -223,8 +231,13 @@ function HomeScreenReady({
|
|||||||
{...props}
|
{...props}
|
||||||
testID="homeScreenFeedTabs"
|
testID="homeScreenFeedTabs"
|
||||||
onPressSelected={onPressSelected}
|
onPressSelected={onPressSelected}
|
||||||
// @ts-expect-error
|
feeds={[
|
||||||
feeds={[{displayName: 'Following'}, {displayName: 'Discover'}]}
|
{
|
||||||
|
displayName: 'Following',
|
||||||
|
uri: TIMELINE_SAVED_FEED.value,
|
||||||
|
},
|
||||||
|
{displayName: 'Discover', uri: DISCOVER_FEED_URI},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {Pressable, View} from 'react-native'
|
import {Pressable, View} from 'react-native'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {useNavigation, useNavigationState} from '@react-navigation/native'
|
import {useNavigation, useNavigationState} from '@react-navigation/native'
|
||||||
|
|
||||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {getLocalizedFeedName} from '#/lib/strings/feed-names'
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
type SavedFeedSourceInfo,
|
type SavedFeedSourceInfo,
|
||||||
@@ -22,7 +22,7 @@ import {useAnalytics} from '#/analytics'
|
|||||||
|
|
||||||
export function DesktopFeeds() {
|
export function DesktopFeeds() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos()
|
const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos()
|
||||||
const selectedFeed = useSelectedFeed()
|
const selectedFeed = useSelectedFeed()
|
||||||
@@ -100,10 +100,9 @@ export function DesktopFeeds() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/feeds"
|
to="/feeds"
|
||||||
label={_(msg`More feeds`)}
|
label={l`More feeds`}
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
@@ -150,7 +149,7 @@ export function DesktopFeeds() {
|
|||||||
: t.atoms.text_contrast_medium,
|
: t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{_(msg`More feeds`)}
|
{l`More feeds`}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -170,19 +169,20 @@ function FeedItem({
|
|||||||
onPress: () => void
|
onPress: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {t: l, i18n} = useLingui()
|
||||||
const {
|
const {
|
||||||
state: hovered,
|
state: hovered,
|
||||||
onIn: onHoverIn,
|
onIn: onHoverIn,
|
||||||
onOut: onHoverOut,
|
onOut: onHoverOut,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
const isFollowing = feedInfo.feedDescriptor === 'following'
|
const isFollowing = feedInfo.feedDescriptor === 'following'
|
||||||
|
const displayName = getLocalizedFeedName(feedInfo, i18n)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={feedInfo.displayName}
|
accessibilityLabel={displayName}
|
||||||
accessibilityHint={_(msg`Opens ${feedInfo.displayName} feed`)}
|
accessibilityHint={l`Opens ${displayName} feed`}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onHoverIn={onHoverIn}
|
onHoverIn={onHoverIn}
|
||||||
onHoverOut={onHoverOut}
|
onHoverOut={onHoverOut}
|
||||||
@@ -231,7 +231,7 @@ function FeedItem({
|
|||||||
: t.atoms.text_contrast_medium,
|
: t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{feedInfo.displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user