Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1be1ba18da | |||
| 2e521d965f | |||
| 3616bfd0b6 | |||
| 9e5e77a210 | |||
| 3246356fa6 | |||
| 076cdd650d | |||
| 56efeee5e2 | |||
| 2ffa02c82a | |||
| 35705ff8bf | |||
| 2c60c45022 | |||
| 8b793d0843 | |||
| 96e6baa47b | |||
| 5be7d72011 | |||
| 4881224d2f |
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
# bundletool needs a JRE. ubuntu-latest ships a default JDK, but pin it explicitly
|
||||
# like the build job so the toolchain is deterministic.
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🤖 Claude
|
||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
||||
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||
with:
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🤖 Claude review
|
||||
uses: anthropics/claude-code-action@459ad358ae43fea66bfefd0a1f8d840b4b9791fb # v1.0.194
|
||||
uses: anthropics/claude-code-action@e5ad3c7725bc2459721893f88879fef9dbcf97b0 # v1.0.202
|
||||
env:
|
||||
ANTHROPIC_BASE_URL: https://agentgateway.k1.prod.bsky.dev
|
||||
with:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
expo-token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: ☕️ Set up Java 17
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
commit_message: Nightly source-language update
|
||||
file_pattern: ./src/locale/locales/en/messages.po
|
||||
- name: 🚀 Push source lang to Crowdin
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_sources_args: "-b main"
|
||||
|
||||
+1
-1
@@ -426,7 +426,7 @@
|
||||
},
|
||||
"app.bsky.embed.video": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.embed.video",
|
||||
"cid": "bafyreiaqos23yv3t4ptrxily6s6qea5fcxfjzjlm42zq46xweby2mkgr4m"
|
||||
"cid": "bafyreihoxb7lvczityqcv2s5od3rllmkee7tn3m4qg6wn34kd6m45p6v24"
|
||||
},
|
||||
"app.bsky.feed.defs": {
|
||||
"uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.feed.defs",
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
"properties": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"maxLength": 10000,
|
||||
"description": "Alt text description of the video, for accessibility.",
|
||||
"maxGraphemes": 1000
|
||||
"description": "Alt text description of the video, for accessibility."
|
||||
},
|
||||
"video": {
|
||||
"type": "blob",
|
||||
@@ -51,9 +49,7 @@
|
||||
],
|
||||
"properties": {
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"maxLength": 10000,
|
||||
"maxGraphemes": 1000
|
||||
"type": "string"
|
||||
},
|
||||
"cid": {
|
||||
"type": "string",
|
||||
|
||||
@@ -61,7 +61,8 @@ The component uses a class-based approach to expose imperative methods (`present
|
||||
- Preserves status/nav bar appearance from host activity
|
||||
- **DialogRootViewGroup.kt**: Custom ViewGroup acting as RootView for the dialog
|
||||
- Forwards touch events to React Native event system
|
||||
- Updates shadow node size to match window dimensions
|
||||
- Reports its measured width to `BottomSheetView` so the content canvas can follow it
|
||||
- Also carries the legacy `UIManagerModule.updateNodeSize()` shadow node sizing, which only runs on the old architecture
|
||||
- Based on React Native's ReactModalHostView pattern
|
||||
- **SheetManager.kt**: Singleton for tracking sheets (same pattern as iOS)
|
||||
|
||||
@@ -74,6 +75,24 @@ Both platforms detect content height changes natively without JS bridge round-tr
|
||||
|
||||
This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
|
||||
|
||||
### Content Canvas Sizing
|
||||
|
||||
The "canvas" is the size the sheet content is laid out on by Yoga. **On Android the native side owns it**; on iOS it is still sized from JS.
|
||||
|
||||
- **Android**: JS renders unsized `flex: 1` content and `BottomSheetView` pushes the canvas size into the Fabric shadow tree through `ExpoView`'s `setViewSize` state channel (`shadowNodeProxy.setViewSize()`). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.
|
||||
- **iOS**: `BottomSheetNativeComponent` sets `height: screenHeight - insets.top` and `width: '100%'` on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).
|
||||
|
||||
How the Android path works:
|
||||
|
||||
- The JS style on the native view **must not set `width` or `height`** on Android. `ExpoViewComponentDescriptor::adopt()` only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.
|
||||
- The two axes come from different places, and the distinction is load-bearing:
|
||||
- **Width** is authoritatively the dialog container's measured width, reported through `DialogRootViewGroup`'s size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from `min(window width, material_bottom_sheet_max_width)` on the first `onLayout` so content has something to lay out in before the dialog exists.
|
||||
- **Height** is always computed natively as `screenHeight - statusBarHeight` (matching the behavior's `expandedOffset`) - the whole expanded frame, **never** the dialog's measured height. The canvas has to be room for the content to grow *into*, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: `BottomSheetBehavior` measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra `ScrollView` padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, `OnLayoutChangeListener` never fires, and the sheet stops responding to its content.
|
||||
- Seeding runs once per open cycle - re-seeding would fight the width the dialog reported and the two would push each other back and forth.
|
||||
- Because the content measures 0x0 until that first state commit lands, `present()` bails out early when the content height is still zero. The commit resizes the native view, which re-fires `onLayout`, which re-enters `present()` - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.
|
||||
- Rotation is handled by the container push: the RN activity handles configuration changes itself, so the view is never recreated. `screenHeight` is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred `updateLayout()` to reposition the sheet).
|
||||
- On the **old architecture** there is no state channel (`stateWrapper` is null, so `setViewSize` no-ops) and Android falls back to `DialogRootViewGroup`'s legacy `UIManagerModule.updateNodeSize()` path. The `present()` gate is skipped there for the same reason - nothing would ever resize the view.
|
||||
|
||||
## Props
|
||||
|
||||
```typescript
|
||||
@@ -213,6 +232,10 @@ BottomSheetNativeComponent.dismissAll()
|
||||
|
||||
4. **Layout Updates During Gestures**: Content height changes are deferred during drag gestures to prevent fighting the user's input.
|
||||
|
||||
5. **Tablet Width**: Material caps the sheet frame at 640dp (`material_bottom_sheet_max_width`, the `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`) and centers it horizontally, so on tablets the sheet is narrower than the screen. `BottomSheetView` reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see [Content Canvas Sizing](#content-canvas-sizing).
|
||||
|
||||
6. **Rotation**: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating `BottomSheetView`. Screen height is therefore read per access rather than cached, and `maxHeight` is stored unclamped and clamped against the current screen at use time.
|
||||
|
||||
### Platform Differences
|
||||
|
||||
- **cornerRadius**: Applied to sheet on iOS, to content wrapper on Android (Android clips with `overflow: hidden`)
|
||||
|
||||
+158
-24
@@ -21,6 +21,12 @@ import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
|
||||
/**
|
||||
* Fallback for Material's `material_bottom_sheet_max_width` dimen (in dp), used only
|
||||
* if the resource lookup fails. 640dp is the value Material ships.
|
||||
*/
|
||||
private const val FALLBACK_MAX_SHEET_WIDTH_DP = 640f
|
||||
|
||||
class BottomSheetView(
|
||||
context: Context,
|
||||
appContext: AppContext,
|
||||
@@ -38,26 +44,30 @@ class BottomSheetView(
|
||||
private var lastObservedContentHeight: Float = 0f
|
||||
private var pendingLayoutUpdate: Boolean = false
|
||||
|
||||
private val screenHeight: Float =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealSize(size)
|
||||
size.y.toFloat()
|
||||
}
|
||||
// Computed per read rather than cached at construction: the RN activity handles
|
||||
// configuration changes itself, so a rotation resizes the display without
|
||||
// recreating this view and a cached value would stay stale for the sheet's life.
|
||||
private val screenHeight: Float
|
||||
get() =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
|
||||
context.resources.displayMetrics.heightPixels
|
||||
.toFloat()
|
||||
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
wm.currentWindowMetrics.bounds
|
||||
.height()
|
||||
.toFloat()
|
||||
} else {
|
||||
// API < 30: currentWindowMetrics not available, use getRealSize
|
||||
// which includes system bars (heightPixels may exclude them)
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getRealSize(size)
|
||||
size.y.toFloat()
|
||||
}
|
||||
|
||||
private fun getNavigationBarHeight(): Int {
|
||||
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
@@ -73,6 +83,11 @@ class BottomSheetView(
|
||||
private val onSnapPointChange by EventDispatcher()
|
||||
private val onStateChange by EventDispatcher()
|
||||
|
||||
// Last canvas size (in dp) pushed into the shadow tree, so repeated layout
|
||||
// passes don't spam state updates
|
||||
private var lastPushedCanvasWidth: Float = -1f
|
||||
private var lastPushedCanvasHeight: Float = -1f
|
||||
|
||||
var disableDrag = false
|
||||
set(value) {
|
||||
field = value
|
||||
@@ -99,10 +114,11 @@ class BottomSheetView(
|
||||
field = if (value < 0) 0f else dpToPx(value)
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
// Stored unclamped (in px) because screenHeight can change under us on rotation.
|
||||
// The clamp against the screen happens at use time, in getTargetHeight().
|
||||
var maxHeight = Float.MAX_VALUE
|
||||
set(value) {
|
||||
val px = dpToPx(value)
|
||||
field = if (px > this.screenHeight) this.screenHeight else px
|
||||
field = dpToPx(value)
|
||||
}
|
||||
|
||||
private var isOpen: Boolean = false
|
||||
@@ -140,6 +156,38 @@ class BottomSheetView(
|
||||
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
|
||||
this.dialogRootViewGroup = DialogRootViewGroup(context)
|
||||
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
|
||||
|
||||
// The dialog container's measured WIDTH is the authoritative canvas width: it
|
||||
// already accounts for the window's horizontal insets, Material's max-width cap on
|
||||
// tablets and the current rotation. DialogRootViewGroup's own updateNodeSize() path
|
||||
// is a no-op on the new architecture (getNativeModule(UIManagerModule) returns null
|
||||
// under Fabric), so this state channel is what actually gets the width across there.
|
||||
//
|
||||
// Its measured HEIGHT is deliberately ignored - see canvasHeight.
|
||||
this.dialogRootViewGroup.setOnSizeChangeListener(
|
||||
object : DialogRootViewGroup.OnSizeChangeListener {
|
||||
override fun onSizeChange(
|
||||
width: Int,
|
||||
height: Int,
|
||||
) {
|
||||
val density = context.resources.displayMetrics.density
|
||||
pushCanvasSize(width / density, canvasHeight / density)
|
||||
|
||||
// onSizeChanged fires from inside a layout pass, so defer the reposition:
|
||||
// updateLayout() reads child heights that aren't final yet. This is what
|
||||
// makes the sheet settle back into place after a rotation. It no-ops for
|
||||
// fullHeight sheets, which is correct - those are pinned to the expanded
|
||||
// offset either way.
|
||||
if ((isOpen || isOpening) && !isClosing) {
|
||||
post {
|
||||
if ((isOpen || isOpening) && !isClosing) {
|
||||
updateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SheetManager.add(this)
|
||||
}
|
||||
@@ -151,9 +199,84 @@ class BottomSheetView(
|
||||
r: Int,
|
||||
b: Int,
|
||||
) {
|
||||
this.seedCanvasSize()
|
||||
this.present()
|
||||
}
|
||||
|
||||
/**
|
||||
* The height, in px, of the canvas the sheet content is laid out on. This is the whole
|
||||
* expanded frame (the behavior's expandedOffset is the status bar height), NOT the
|
||||
* sheet's current height.
|
||||
*
|
||||
* That distinction is the whole ballgame. The content's height is what drives the snap
|
||||
* points, so the canvas has to be room to grow *into*. Sizing the canvas from the
|
||||
* dialog's own measured height is circular - BottomSheetBehavior measures the dialog
|
||||
* container against the sheet, so the canvas collapses onto the content height, and from
|
||||
* then on the content is pinned: extra ScrollView padding (the Android keyboard path) or
|
||||
* a longer list just becomes scroll extent instead of a height change, the
|
||||
* OnLayoutChangeListener never fires, and the sheet stops responding to its content.
|
||||
*/
|
||||
private val canvasHeight: Float
|
||||
get() = screenHeight - getStatusBarHeight()
|
||||
|
||||
/**
|
||||
* JS renders the sheet content unsized, so before the first state commit it measures
|
||||
* 0x0 and present() has no content height to derive snap points from. Seed the canvas
|
||||
* here to kick that off - the dialog container reports the authoritative width later,
|
||||
* via its OnSizeChangeListener.
|
||||
*
|
||||
* Runs at most once per open cycle. It has to: each state commit re-fires onLayout, so
|
||||
* re-seeding would fight the width the dialog reported and the two would push each other
|
||||
* back and forth forever.
|
||||
*
|
||||
* stateWrapper is assigned while Fabric mounts the view, before the first layout pass,
|
||||
* so setViewSize() should already reach the shadow tree from here. On the old
|
||||
* architecture it is null and this no-ops, which is fine: DialogRootViewGroup's legacy
|
||||
* updateNodeSize() path still sizes the content there.
|
||||
*/
|
||||
private fun seedCanvasSize() {
|
||||
if (lastPushedCanvasWidth > 0f) return
|
||||
val density = context.resources.displayMetrics.density
|
||||
val widthPx =
|
||||
minOf(
|
||||
context.resources.displayMetrics.widthPixels
|
||||
.toFloat(),
|
||||
getMaxSheetWidth(),
|
||||
)
|
||||
this.pushCanvasSize(widthPx / density, canvasHeight / density)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the size of this view's shadow node, which is the canvas the sheet content is
|
||||
* laid out on. Deduped because both onLayout and the dialog container's size changes
|
||||
* can re-report an unchanged size.
|
||||
*/
|
||||
private fun pushCanvasSize(
|
||||
widthDp: Float,
|
||||
heightDp: Float,
|
||||
) {
|
||||
if (widthDp <= 0f || heightDp <= 0f) return
|
||||
if (widthDp == lastPushedCanvasWidth && heightDp == lastPushedCanvasHeight) return
|
||||
lastPushedCanvasWidth = widthDp
|
||||
lastPushedCanvasHeight = heightDp
|
||||
this.shadowNodeProxy.setViewSize(widthDp.toDouble(), heightDp.toDouble())
|
||||
}
|
||||
|
||||
/**
|
||||
* Material caps the sheet frame at `material_bottom_sheet_max_width` (the
|
||||
* `android:maxWidth` on `Widget.MaterialComponents.BottomSheet`, which our dialog theme
|
||||
* inherits from) and centers it horizontally, so on tablets the sheet is narrower than
|
||||
* the display. Returns the cap in px.
|
||||
*/
|
||||
private fun getMaxSheetWidth(): Float =
|
||||
try {
|
||||
resources
|
||||
.getDimensionPixelSize(com.google.android.material.R.dimen.material_bottom_sheet_max_width)
|
||||
.toFloat()
|
||||
} catch (e: android.content.res.Resources.NotFoundException) {
|
||||
FALLBACK_MAX_SHEET_WIDTH_DP * context.resources.displayMetrics.density
|
||||
}
|
||||
|
||||
private fun destroy() {
|
||||
this.stopObservingContentHeight()
|
||||
this.isClosing = false
|
||||
@@ -178,6 +301,15 @@ class BottomSheetView(
|
||||
|
||||
val contentHeight = this.getContentHeight()
|
||||
|
||||
// The content is unsized until the canvas size we pushed lands in the shadow tree,
|
||||
// so bail and let this retry itself: the state commit resizes this view, that
|
||||
// re-fires onLayout, and onLayout re-enters present(). Full-height sheets don't
|
||||
// need a content measurement, so they can go ahead immediately.
|
||||
//
|
||||
// Only gate when there is a state channel to wait on. Without one (old architecture)
|
||||
// nothing would ever resize this view, and the sheet would never present.
|
||||
if (stateWrapper != null && !fullHeight && contentHeight <= 0f) return
|
||||
|
||||
var activityWindow: Window? = null
|
||||
var currentContext = context
|
||||
while (currentContext != null) {
|
||||
@@ -425,8 +557,10 @@ class BottomSheetView(
|
||||
|
||||
private fun getTargetHeight(): Float {
|
||||
val contentHeight = this.getContentHeight()
|
||||
// maxHeight is stored unclamped, so clamp it against the current screen here
|
||||
val effectiveMaxHeight = minOf(this.maxHeight, this.screenHeight)
|
||||
return when {
|
||||
contentHeight > maxHeight -> maxHeight
|
||||
contentHeight > effectiveMaxHeight -> effectiveMaxHeight
|
||||
contentHeight < minHeight -> minHeight
|
||||
else -> contentHeight
|
||||
}
|
||||
|
||||
@@ -34,10 +34,6 @@ const IS_IOS15 =
|
||||
Platform.OS === 'ios' &&
|
||||
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||
Number(Platform.Version.split('.').at(0)) < 16
|
||||
// older android versions (15 and below) aren't naturally edge-to-edge
|
||||
// and behave a little differently
|
||||
const IS_NON_E2E_ANDROID =
|
||||
Platform.OS === 'android' && Number(Platform.Version) < 35
|
||||
|
||||
export class BottomSheetNativeComponent extends Component<
|
||||
BottomSheetViewProps,
|
||||
@@ -148,24 +144,35 @@ function BottomSheetNativeComponentInner({
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const isHeightConstrained = maxHeight != null || rest.fullHeight === true
|
||||
|
||||
// sigh... on older Android versions, screenHeight does not include safe area insets
|
||||
// on newer Androids + iOS, it does. we need to find the inner bit + the bottom inset
|
||||
// for the sheet content
|
||||
const sheetHeight = IS_NON_E2E_ANDROID
|
||||
? screenHeight + insets.bottom
|
||||
: screenHeight - insets.top
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...rest}
|
||||
maxHeight={maxHeight}
|
||||
onStateChange={onStateChange}
|
||||
ref={nativeViewRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: sheetHeight,
|
||||
width: '100%',
|
||||
}}
|
||||
/*
|
||||
* On Android the native side owns this view's size - the canvas the sheet
|
||||
* content is laid out on - and pushes it into the Fabric shadow tree through
|
||||
* ExpoView's `setViewSize` state channel. It knows the real sheet frame
|
||||
* (window insets, Material's max-width cap on tablets, rotation), which JS
|
||||
* can only guess at. `width` and `height` must stay unset there:
|
||||
* `ExpoViewComponentDescriptor::adopt()` only applies the state size on an
|
||||
* axis where the style leaves that dimension undefined, so a style dimension
|
||||
* would silently win and clip the content again.
|
||||
*
|
||||
* iOS still sizes the canvas from JS. Moving it onto the same state channel
|
||||
* needs on-device iteration on iOS 26 sheet geometry (large-detent and
|
||||
* floating-card metrics), so it is deferred.
|
||||
*/
|
||||
style={
|
||||
Platform.OS === 'ios'
|
||||
? {
|
||||
position: 'absolute',
|
||||
height: screenHeight - insets.top,
|
||||
width: '100%',
|
||||
}
|
||||
: {position: 'absolute'}
|
||||
}
|
||||
containerBackgroundColor={backgroundColor}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"bcp-47": "^2.1.0",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"bidi-js": "^1.0.3",
|
||||
"date-fns": "^4.4.0",
|
||||
"email-validator": "^2.0.4",
|
||||
"emoji-mart": "^5.6.0",
|
||||
|
||||
Generated
+10
@@ -421,6 +421,9 @@ importers:
|
||||
bcp-47-match:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
bidi-js:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -4523,6 +4526,9 @@ packages:
|
||||
bcp-47@2.1.0:
|
||||
resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
big-integer@1.6.52:
|
||||
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -14010,6 +14016,10 @@ snapshots:
|
||||
is-alphanumerical: 2.0.1
|
||||
is-decimal: 2.0.1
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
big-integer@1.6.52: {}
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {Error} from '#/components/Error'
|
||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from '#/components/icons/Emoji'
|
||||
import {useOtherRequiredDataQuery} from '#/ageAssurance/data'
|
||||
import {IS_WEB} from '#/env'
|
||||
|
||||
export function DataUnavailableScreen() {
|
||||
const {t: l} = useLingui()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const {isFetching, refetch} = useOtherRequiredDataQuery()
|
||||
|
||||
return (
|
||||
<Error
|
||||
icon={EmojiSadIcon}
|
||||
title={l`Unable to load your account`}
|
||||
message={l`We couldn't load your account settings. Check your internet connection and try again.`}
|
||||
onRetry={() => void refetch()}
|
||||
isRetrying={isFetching}
|
||||
secondaryAction={{
|
||||
label: l`Sign out`,
|
||||
onPress: () => {
|
||||
if (IS_WEB) history.pushState(null, '', '/')
|
||||
logoutCurrentAccount('AgeAssuranceDataUnavailableScreen')
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
|
||||
import {persistQueryClient} from '@tanstack/react-query-persist-client'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {isRetryableRequestError, networkRetry} from '#/lib/async/retry'
|
||||
import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {
|
||||
@@ -347,9 +347,15 @@ export type OtherRequiredData = {
|
||||
birthdate: string | undefined
|
||||
actorDeclaration?: chat.bsky.actor.declaration.Main
|
||||
}
|
||||
export type OtherRequiredDataStatus = 'pending' | 'error' | 'success'
|
||||
const otherRequiredDataRetryOptions = {
|
||||
retry: (failureCount: number, error: unknown) =>
|
||||
failureCount < 2 && isRetryableRequestError(error),
|
||||
}
|
||||
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
|
||||
return ['otherRequiredData', did]
|
||||
}
|
||||
|
||||
async function getOtherRequiredData({
|
||||
accountClient,
|
||||
}: {
|
||||
@@ -455,10 +461,11 @@ export async function prefetchOtherRequiredData({
|
||||
|
||||
try {
|
||||
logger.debug(`prefetchOtherRequiredData: resolving...`)
|
||||
const res = await networkRetry(3, () =>
|
||||
getOtherRequiredData({accountClient}),
|
||||
)
|
||||
qc.setQueryData<OtherRequiredData>(qk, res)
|
||||
await qc.fetchQuery({
|
||||
...otherRequiredDataRetryOptions,
|
||||
queryKey: qk,
|
||||
queryFn: () => getOtherRequiredData({accountClient}),
|
||||
})
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
logger.warn(`prefetchOtherRequiredData: failed`, {
|
||||
@@ -490,12 +497,14 @@ export function useOtherRequiredDataQuery() {
|
||||
const did = accountClient.did
|
||||
return useQuery(
|
||||
{
|
||||
...otherRequiredDataRetryOptions,
|
||||
enabled: !!did,
|
||||
initialData: () => {
|
||||
if (!did) return
|
||||
return getOtherRequiredDataFromCache({did})
|
||||
},
|
||||
queryKey: createOtherRequiredDataQueryKey({did: did!}),
|
||||
retryOnMount: false,
|
||||
async queryFn() {
|
||||
return getOtherRequiredData({accountClient})
|
||||
},
|
||||
@@ -722,6 +731,11 @@ export type AgeAssuranceServerData = {
|
||||
*/
|
||||
state: app.bsky.ageassurance.defs.State | undefined
|
||||
metadata: AgeAssuranceMetadata | undefined
|
||||
/**
|
||||
* Whether the account data needed to compute age assurance is available.
|
||||
* A successful response without a birthdate is still `success`.
|
||||
*/
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
/**
|
||||
* The native on-device age signals for the region the user is currently in,
|
||||
* if they've granted access there. Already resolved from the region-keyed
|
||||
@@ -739,6 +753,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
declaredAge: undefined,
|
||||
birthdate: undefined,
|
||||
},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
deviceSignals: undefined,
|
||||
})
|
||||
export function useAgeAssuranceServerDataContext() {
|
||||
@@ -752,7 +767,18 @@ export function AgeAssuranceServerDataProvider({
|
||||
const {data: config} = useConfigQuery()
|
||||
const serverState = useServerStateQuery()
|
||||
const {state, metadata} = serverState.data || {}
|
||||
const {data} = useOtherRequiredDataQuery()
|
||||
const {data, errorUpdatedAt, status} = useOtherRequiredDataQuery()
|
||||
/*
|
||||
* A data-less query returns to `pending` and clears `error` while refetching,
|
||||
* but retains `errorUpdatedAt`. Keep the error screen mounted until data
|
||||
* loads successfully.
|
||||
*/
|
||||
const otherRequiredDataStatus: OtherRequiredDataStatus =
|
||||
data !== undefined
|
||||
? 'success'
|
||||
: status === 'error' || errorUpdatedAt > 0
|
||||
? 'error'
|
||||
: 'pending'
|
||||
// `select` resolves the cached region-keyed map to the current region.
|
||||
const {data: deviceSignals} = useDeviceSignalsQuery()
|
||||
const ctx = useMemo(
|
||||
@@ -767,9 +793,10 @@ export function AgeAssuranceServerDataProvider({
|
||||
: undefined,
|
||||
birthdate: data?.birthdate,
|
||||
},
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[config, state, data, metadata, deviceSignals],
|
||||
[config, state, data, metadata, otherRequiredDataStatus, deviceSignals],
|
||||
)
|
||||
return (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {computeAgeAssuranceState} from '#/ageAssurance/state'
|
||||
import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types'
|
||||
|
||||
jest.mock('#/ageAssurance/data', () => ({}))
|
||||
jest.mock('#/ageAssurance/logger', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}))
|
||||
jest.mock('#/state/session', () => ({}))
|
||||
|
||||
const geolocation = {
|
||||
countryCode: undefined,
|
||||
regionCode: undefined,
|
||||
}
|
||||
|
||||
describe('computeAgeAssuranceState', () => {
|
||||
it('computes access while required account data is pending', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'pending',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('denies access when required account data fails', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toEqual({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data',
|
||||
})
|
||||
})
|
||||
|
||||
it('computes access after a successful response without a birthdate', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation,
|
||||
config: {regions: []},
|
||||
metadata: {birthdate: undefined},
|
||||
otherRequiredDataStatus: 'success',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves authoritative terminal server state without account data', () => {
|
||||
expect(
|
||||
computeAgeAssuranceState({
|
||||
hasSession: true,
|
||||
geolocation: {countryCode: 'AA', regionCode: undefined},
|
||||
config: {
|
||||
regions: [
|
||||
{
|
||||
countryCode: 'AA',
|
||||
minAccessAge: 13,
|
||||
rules: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
state: {status: 'blocked', access: 'none'},
|
||||
otherRequiredDataStatus: 'error',
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: AgeAssuranceStatus.Blocked,
|
||||
access: AgeAssuranceAccess.None,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDeviceSignalsFromCacheForRegion,
|
||||
getOtherRequiredDataFromCache,
|
||||
getServerStateFromCache,
|
||||
type OtherRequiredDataStatus,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {logger} from '#/ageAssurance/logger'
|
||||
@@ -35,12 +36,13 @@ import {device} from '#/storage'
|
||||
* server state before computing access based on AA config from the server +
|
||||
* geolocation and other data.
|
||||
*/
|
||||
function computeAgeAssuranceState({
|
||||
export function computeAgeAssuranceState({
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}: {
|
||||
hasSession: boolean
|
||||
@@ -48,6 +50,7 @@ function computeAgeAssuranceState({
|
||||
config?: app.bsky.ageassurance.defs.Config
|
||||
state?: app.bsky.ageassurance.defs.State
|
||||
metadata?: AgeAssuranceMetadata
|
||||
otherRequiredDataStatus: OtherRequiredDataStatus
|
||||
deviceSignals?: AgeRange.AgeRangeResponse
|
||||
}) {
|
||||
/**
|
||||
@@ -93,6 +96,14 @@ function computeAgeAssuranceState({
|
||||
}
|
||||
}
|
||||
|
||||
if (otherRequiredDataStatus === 'error') {
|
||||
return {
|
||||
status: AgeAssuranceStatus.Unknown,
|
||||
access: AgeAssuranceAccess.None,
|
||||
error: 'account-data' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Otherwise, we need to compute the access based on the latest data. For
|
||||
* accounts with an accurate birthdate, our default fallback rules should
|
||||
@@ -177,6 +188,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
geolocation,
|
||||
state: state.state,
|
||||
metadata,
|
||||
otherRequiredDataStatus: 'success',
|
||||
deviceSignals,
|
||||
})
|
||||
|
||||
@@ -194,7 +206,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
||||
export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
const {hasSession} = useSession()
|
||||
const geolocation = useGeolocation()
|
||||
const {config, state, metadata, deviceSignals} =
|
||||
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
||||
useAgeAssuranceServerDataContext()
|
||||
|
||||
return useMemo(
|
||||
@@ -205,9 +217,18 @@ export function useAgeAssuranceState(): AgeAssuranceState {
|
||||
geolocation,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
}),
|
||||
[hasSession, geolocation, config, state, metadata, deviceSignals],
|
||||
[
|
||||
hasSession,
|
||||
geolocation,
|
||||
config,
|
||||
state,
|
||||
metadata,
|
||||
otherRequiredDataStatus,
|
||||
deviceSignals,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ export type AgeAssuranceState = {
|
||||
lastInitiatedAt?: string
|
||||
status: AgeAssuranceStatus
|
||||
access: AgeAssuranceAccess
|
||||
error?: 'config' // maybe other specific cases in the future
|
||||
isLoading?: boolean
|
||||
error?: 'config' | 'account-data'
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
|
||||
@@ -44,6 +44,7 @@ export type Events = {
|
||||
| 'SignupQueued'
|
||||
| 'Deactivated'
|
||||
| 'Takendown'
|
||||
| 'AgeAssuranceDataUnavailableScreen'
|
||||
| 'AgeAssuranceNoAccessScreen'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
@@ -450,6 +451,7 @@ export type Events = {
|
||||
'post:view': {
|
||||
uri: string
|
||||
authorDid: string
|
||||
isReply: boolean
|
||||
logContext:
|
||||
| 'FeedItem'
|
||||
| 'PostThreadItem'
|
||||
|
||||
+59
-13
@@ -3,27 +3,38 @@ import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useGoBack} from '#/lib/hooks/useGoBack'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Error({
|
||||
icon: Icon,
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
secondaryAction,
|
||||
isRetrying,
|
||||
}: {
|
||||
icon?: React.ComponentType<SVGIconProps>
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
hideBackButton?: boolean
|
||||
isRetrying?: boolean
|
||||
secondaryAction?: {
|
||||
label: string
|
||||
accessibilityLabel?: string
|
||||
onPress: () => unknown
|
||||
}
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<Layout.Center
|
||||
@@ -35,8 +46,11 @@ export function Error({
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg, a.px_md]}>
|
||||
{Icon && <Icon size="4xl" fill={t.atoms.text_contrast_medium.color} />}
|
||||
<Text style={[a.font_semi_bold, a.text_3xl, a.text_center]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
@@ -51,29 +65,61 @@ export function Error({
|
||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={l`Press to retry`}
|
||||
onPress={onRetry}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
{isRetrying && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
)}
|
||||
{!hideBackButton && (
|
||||
{!hideBackButton && secondaryAction ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={l`Return to previous page`}
|
||||
onPress={goBack}
|
||||
label={secondaryAction.accessibilityLabel ?? secondaryAction.label}
|
||||
onPress={secondaryAction.onPress}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
<ButtonText>{secondaryAction.label}</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
) : !hideBackButton ? (
|
||||
<GoBackButton
|
||||
hasRetry={Boolean(onRetry)}
|
||||
isRetrying={isRetrying}
|
||||
onGoBack={onGoBack}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</Layout.Center>
|
||||
)
|
||||
}
|
||||
|
||||
function GoBackButton({
|
||||
hasRetry,
|
||||
isRetrying,
|
||||
onGoBack,
|
||||
}: {
|
||||
hasRetry: boolean
|
||||
isRetrying?: boolean
|
||||
onGoBack?: () => unknown
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color={hasRetry ? 'secondary' : 'primary'}
|
||||
label={l`Return to previous page`}
|
||||
onPress={goBack}
|
||||
disabled={isRetrying}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useMemo} from 'react'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {RichText as RichTextAPI} from '@bsky/sdk/richtext'
|
||||
|
||||
import {isRTLText} from '#/lib/strings/text-direction'
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
import {android, atoms as a, flatten, type TextStyleProp} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
@@ -9,6 +10,7 @@ import {InlineLinkText, type LinkProps} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {RichTextTag} from '#/components/RichTextTag'
|
||||
import {Text, type TextProps} from '#/components/Typography'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {app} from '#/lexicons'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
@@ -82,15 +84,17 @@ export function RichText({
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const plainStyles = style
|
||||
const {text, facets} = richText
|
||||
const plainStyles: StyleProp<TextStyle> = [
|
||||
style,
|
||||
IS_NATIVE && isRTLText(text) ? {textAlign: 'right'} : null,
|
||||
]
|
||||
const suffixStyles =
|
||||
suffix && suffixOffset
|
||||
? android({paddingBottom: suffixOffset, marginBottom: -suffixOffset})
|
||||
: null
|
||||
const interactiveStyles = [plainStyles, interactiveStyle]
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const flattenedStyle = flatten(style)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {EMBED_SCRIPT} from '#/lib/constants'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
@@ -103,7 +103,7 @@ function EmbedDialogInner({
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
|
||||
<Dialog.Inner label={_(msg`Embed post`)} style={[web({maxWidth: 500})]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
@@ -45,7 +45,7 @@ function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Sign in to Bluesky or create a new account`)}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
|
||||
style={[a.w_full, gtMobile && web({width: 'auto', maxWidth: 420})]}>
|
||||
<View style={[!IS_NATIVE && a.p_2xl]}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
@@ -38,7 +38,8 @@ export function InitialVerificationAnnouncement() {
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Announcing verification on Bluesky`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
a.w_full,
|
||||
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
|
||||
]}>
|
||||
<View style={[a.align_start, a.gap_xl]}>
|
||||
<View
|
||||
|
||||
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {usePdsClient, useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
@@ -62,7 +62,8 @@ function Inner({}: {control: DialogControlProps}) {
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
a.w_full,
|
||||
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
|
||||
]}>
|
||||
<View style={[a.gap_xl]}>
|
||||
{status === 'loading' ? (
|
||||
|
||||
@@ -18,7 +18,7 @@ import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {atoms as a, useGutters, useTheme, web} from '#/alf'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -334,7 +334,7 @@ function Inner(
|
||||
testID="report:dialog"
|
||||
label={l`Report dialog`}
|
||||
ref={ref}
|
||||
style={[a.w_full, {maxWidth: 500}]}>
|
||||
style={[a.w_full, web({maxWidth: 500})]}>
|
||||
<View style={[a.gap_2xl, IS_NATIVE && a.pt_md]}>
|
||||
<StepOuter>
|
||||
<StepTitle
|
||||
|
||||
@@ -8,7 +8,7 @@ import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -78,7 +78,8 @@ function Inner({
|
||||
<Dialog.ScrollableInner
|
||||
label={label}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
a.w_full,
|
||||
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
|
||||
]}>
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold, a.pr_4xl, a.leading_tight]}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {urls} from '#/lib/constants'
|
||||
import {getUserDisplayName} from '#/lib/getUserDisplayName'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {VerifierCheck} from '#/components/icons/VerifierCheck'
|
||||
@@ -65,7 +65,8 @@ function Inner({
|
||||
<Dialog.ScrollableInner
|
||||
label={label}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
a.w_full,
|
||||
gtMobile && web({width: 'auto', maxWidth: 400, minWidth: 200}),
|
||||
]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View
|
||||
|
||||
Vendored
+8
@@ -1,2 +1,10 @@
|
||||
// TS6.0 enables noUncheckedSideEffectImports
|
||||
declare module '*.css'
|
||||
|
||||
declare module 'bidi-js' {
|
||||
type Bidi = {
|
||||
getBidiCharTypeName(character: string): string
|
||||
}
|
||||
|
||||
export default function bidiFactory(): Bidi
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {isRetryableRequestError} from '#/lib/async/retry'
|
||||
|
||||
describe('retry', () => {
|
||||
it('identifies retryable request errors', () => {
|
||||
expect(isRetryableRequestError(new TypeError('Failed to fetch'))).toBe(true)
|
||||
expect(isRetryableRequestError(new Error('Invalid request'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {isNetworkError, shouldRetryError} from '#/lib/strings/errors'
|
||||
|
||||
export function isRetryableRequestError(error: unknown) {
|
||||
return isNetworkError(error) || shouldRetryError(error)
|
||||
}
|
||||
|
||||
export async function retry<P>(
|
||||
retries: number,
|
||||
|
||||
@@ -24,6 +24,7 @@ export function usePostViewTracking(
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
isReply: !!post.record.reply,
|
||||
logContext,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,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,17 @@
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
const bidi = bidiFactory()
|
||||
|
||||
/**
|
||||
* Checks the first strong directional character, matching HTML `dir="auto"`.
|
||||
*/
|
||||
export function isRTLText(text: string) {
|
||||
for (const character of text) {
|
||||
const type = bidi.getBidiCharTypeName(character)
|
||||
|
||||
if (type === 'R' || type === 'AL') return true
|
||||
if (type === 'L') return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* React Native Web sets `dir="auto"` on root Text elements, so the browser
|
||||
* handles direction detection without JavaScript.
|
||||
*/
|
||||
export function isRTLText(_text: string) {
|
||||
return false
|
||||
}
|
||||
+471
-465
File diff suppressed because it is too large
Load Diff
@@ -395,11 +395,13 @@ export function CustomFeedHeader({
|
||||
) : null}
|
||||
</Layout.Header.Outer>
|
||||
</Layout.Center>
|
||||
<Dialog.Outer control={infoControl}>
|
||||
<Dialog.Outer
|
||||
control={infoControl}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Feed menu`}
|
||||
style={[gtMobile ? {width: 'auto', minWidth: 450} : a.w_full]}>
|
||||
style={[a.w_full, gtMobile && web({width: 'auto', minWidth: 450})]}>
|
||||
<DialogInner
|
||||
info={info}
|
||||
likeUri={likeUri}
|
||||
|
||||
@@ -329,6 +329,7 @@ export function StepProfile() {
|
||||
{
|
||||
width: 'auto',
|
||||
maxWidth: 410,
|
||||
marginHorizontal: 'auto',
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_center, {paddingTop: 20}]}>
|
||||
|
||||
@@ -104,6 +104,7 @@ export function PostThread({uri}: {uri: string}) {
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
isReply: !!post.record.reply,
|
||||
logContext: 'Post',
|
||||
feedDescriptor: feedFeedback.feedDescriptor,
|
||||
})
|
||||
|
||||
@@ -508,6 +508,7 @@ let VideoItem = ({
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
isReply: !!post.record.reply,
|
||||
logContext: 'ImmersiveVideo',
|
||||
feedDescriptor,
|
||||
})
|
||||
|
||||
@@ -1681,6 +1681,8 @@ let ComposerPost = memo(function ComposerPost({
|
||||
|
||||
return (
|
||||
<View
|
||||
// Keep focused inputs attached while active-state opacity changes.
|
||||
collapsable={false}
|
||||
style={[
|
||||
a.mx_lg,
|
||||
a.mb_sm,
|
||||
|
||||
@@ -101,7 +101,7 @@ function DialogInner({
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Add a content warning`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
style={[a.w_full, web({maxWidth: 500})]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold]}>
|
||||
|
||||
@@ -1077,15 +1077,22 @@ let PostFeed = ({
|
||||
|
||||
onPostSeen(post)
|
||||
|
||||
// Only track the root post of each slice (index 0) to avoid double-counting thread items
|
||||
if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
|
||||
// Track the post selected by the feed once it is actually visible.
|
||||
if (
|
||||
post.uri === slice.feedPostUri &&
|
||||
!seenPostUrisRef.current.has(post.uri)
|
||||
) {
|
||||
seenPostUrisRef.current.add(post.uri)
|
||||
|
||||
const position = getPostPosition('sliceItem', item.key)
|
||||
const position = getPostPosition(
|
||||
'sliceItem',
|
||||
slice.items[0]._reactKey,
|
||||
)
|
||||
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
isReply: !!postItem.record.reply,
|
||||
logContext: 'FeedItem',
|
||||
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
||||
position,
|
||||
@@ -1121,6 +1128,7 @@ let PostFeed = ({
|
||||
ax.metric('post:view', {
|
||||
uri: post.uri,
|
||||
authorDid: post.author.did,
|
||||
isReply: !!postItem.record.reply,
|
||||
logContext: 'FeedItem',
|
||||
feedDescriptor: feedFeedback.feedDescriptor || feed,
|
||||
position,
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '#/components/PolicyUpdateOverlay'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||
@@ -245,7 +246,9 @@ export function Shell() {
|
||||
<Deactivated />
|
||||
) : (
|
||||
<>
|
||||
{aa.state.access === aa.Access.None ? (
|
||||
{aa.state.error === 'account-data' ? (
|
||||
<DataUnavailableScreen />
|
||||
) : aa.state.access === aa.Access.None ? (
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {WelcomeModal} from '#/components/WelcomeModal'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {DataUnavailableScreen} from '#/ageAssurance/components/DataUnavailableScreen'
|
||||
import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen'
|
||||
import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {PassiveAnalytics} from '#/analytics/PassiveAnalytics'
|
||||
@@ -167,7 +168,9 @@ export function Shell() {
|
||||
<Deactivated />
|
||||
) : (
|
||||
<>
|
||||
{aa.state.access === aa.Access.None ? (
|
||||
{aa.state.error === 'account-data' ? (
|
||||
<DataUnavailableScreen />
|
||||
) : aa.state.access === aa.Access.None ? (
|
||||
<NoAccessScreen />
|
||||
) : (
|
||||
<RoutesContainer>
|
||||
|
||||
Reference in New Issue
Block a user