Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [Unreleased]

### Changes
- **Breaking since `2.16.0-rc`:** `onFail` of `MindboxEmbeddedBlock` reports failures only; a place with nothing to show arrives in the new `onEmpty`. A host that hid its section in `onFail` — as the previous README showed — moves that to `onEmpty`.
- **Breaking since `2.16.0-rc`:** the `MindboxEmbeddedBlockFailure` type is removed; `onFail` receives a `MindboxEmbeddedBlockFailReason` string instead of an empty object.
- **Breaking since `2.16.0-rc`:** `MindboxEmbeddedBlock` starts by `loadingStrategy="automatic"` — hidden until the place has shown content once on this device — instead of always showing a placeholder; `loadingStrategy="placeholder"` restores the previous look.
- Add `MindboxEmbeddedBlock` — an embedded block for a place from the admin panel, a native component for both the old and the new architecture.
- `MindboxEmbeddedBlock` reports three outcomes, as the native SwiftUI and Compose blocks do: `onLoad`, `onEmpty` and `onFail` with a `MindboxEmbeddedBlockFailReason` (`networkError` or `internalError`).
- `MindboxEmbeddedBlock` takes a `loadingStrategy` — `automatic` (the default: hidden until the place has shown content once on this device, a placeholder from then on), `placeholder` or `hidden` — and `animatesReveal`, as the native SwiftUI and Compose blocks do. A block that waited hidden grows to its height with the SDK's reveal when its content arrives.

## [2.16.0-rc] - 2026-09-11

### Changes
Expand Down
53 changes: 48 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,28 @@ import { MindboxEmbeddedBlock } from 'mindbox-sdk';
<MindboxEmbeddedBlock placeSystemName="main-screen-top" height={104} />
```

Both outcomes can be customized, the same way as in SwiftUI, Compose and Flutter: `placeholder`
The outcome arrives through three callbacks, the same three as in SwiftUI, Compose and Flutter:
`onLoad` when the content is shown, `onEmpty` when there is nothing to show at the place, and
`onFail` with a `MindboxEmbeddedBlockFailReason` when the block could not be shown. An empty place
is a normal outcome, not a breakage, and comes with no reason; a block whose place system name is
empty is such a place too. A failure's reason — `networkError`
or `internalError` — is for logs and analytics, not for branching: by the time it arrives the block
has already collapsed or switched to `error`. A later SDK may add reasons, so keep a fallback when
matching.

Both looks can be customized, the same way as in SwiftUI, Compose and Flutter: `placeholder`
replaces the stock loading shimmer, and `error` opts into showing a failure instead of collapsing.
An empty place always collapses — a host cannot fill the space of a block that was never meant to
be there. `onLoad` and `onFail` report how the load ended.
be there.

```tsx
<MindboxEmbeddedBlock
placeSystemName="stories"
height={104}
placeholder={<StoriesSkeleton />}
error={<StoriesUnavailable />}
onFail={() => setShowStoriesSection(false)}
onEmpty={() => setShowStoriesSection(false)}
onFail={(reason) => console.log(`stories failed: ${reason}`)}
/>
```

Expand All @@ -78,10 +88,43 @@ a screen nobody is looking at.
/>
```

What the block shows until the SDK has decided what goes into it is `loadingStrategy`, the same
three choices as in SwiftUI, Compose and Flutter. `automatic` — the default — keeps the block hidden
until the place has shown content once on this device and puts a placeholder there from then on:
nothing flashes where no content is expected, and where it is expected the space is taken as soon
as the native side has read the place's memory — a frame after the block is mounted, so what stands
below moves down by the block's height once. `placeholder` takes the space from the first frame,
worth naming for a place that always has a campaign behind it. `hidden` never takes it until the
content is shown.

A block that waits hidden — `hidden`, and `automatic` at a place that has not shown content yet —
draws neither `placeholder` nor `error` until its content has been shown once: a failure keeps it
collapsed, and only `onFail` tells. With the default strategy that is every place on a fresh
install, so a host that counts on its `error` screen names `loadingStrategy="placeholder"`.

The content is revealed with the SDK's own animation — it fades in, and a block that started hidden
grows to its height — unless `animatesReveal` is off; the system's reduced-motion setting turns it
off as well. Turn it off to animate the block's container yourself in `onLoad` — keeping in mind
that a block that waited hidden is only starting to grow from zero at that moment. A host
`placeholder` the content replaces fades out above it over the same reveal.

```tsx
<MindboxEmbeddedBlock
placeSystemName="stories"
height={104}
loadingStrategy="placeholder"
animatesReveal={false}
/>
```

`height` is live: a new value resizes a block already on screen in place — the same content, no
reload. It has to be positive, though: a block given no space to occupy is never loaded and reports
no outcome. `timeoutMs` is fixed when the block is created — a new value is ignored with a warning;
give the component a new `key` to load a block on a new budget.
no outcome. `timeoutMs`, `loadingStrategy` and `animatesReveal` are fixed when the block is created
— a new value is ignored with a warning; give the component a new `key` to build a block anew.

Available on iOS and Android. On any other platform the block collapses right away and reports
`onFail` with `internalError`, so a layout that hides its section on failure behaves the same
everywhere.

### Push Notifications

Expand Down
31 changes: 31 additions & 0 deletions android/src/main/java/com/mindboxsdk/embedded/EmbeddedBlockWire.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.mindboxsdk.embedded

import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockLoadingStrategy

/**
* The words the block speaks to JS in: the same ones on iOS, the same ones the Flutter plugin
* uses. A contract with the JS side, not derived from anything.
*/
@OptIn(InternalMindboxApi::class)
internal object EmbeddedBlockWire {
const val OUTCOME_LOAD = "load"
const val OUTCOME_EMPTY = "empty"
const val OUTCOME_FAIL = "fail"

/** `null` for a word this SDK does not know; an empty word is the default, `automatic`. */
fun loadingStrategyOf(word: String?): MindboxEmbeddedBlockLoadingStrategy? = when (word) {
null, "", "automatic" -> MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC
"placeholder" -> MindboxEmbeddedBlockLoadingStrategy.PLACEHOLDER
"hidden" -> MindboxEmbeddedBlockLoadingStrategy.HIDDEN
else -> null
}

fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) {
MindboxEmbeddedBlockAppearance.PLACEHOLDER -> "placeholder"
MindboxEmbeddedBlockAppearance.CONTENT -> "content"
MindboxEmbeddedBlockAppearance.ERROR -> "error"
MindboxEmbeddedBlockAppearance.COLLAPSED -> "collapsed"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,57 @@ import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event

/**
* The look the native block shows. `animated` is `true` for the one change that is the SDK's
* reveal of content — the block owns that decision, gates included — and `revealDurationMs` is
* how long it takes; `false` and `0` for every other change.
*/
internal class AppearanceChangeEvent(
surfaceId: Int,
viewTag: Int,
private val appearance: String,
private val isRevealAnimated: Boolean,
private val revealDurationMs: Int,
) : Event<AppearanceChangeEvent>(surfaceId, viewTag) {
Comment thread
Vailence marked this conversation as resolved.
override fun getEventName(): String = EVENT_NAME

// React Native coalesces same-named events of one view that are still queued when a frame is
// dispatched, keeping the last. The JS side reads every change — a reveal is content arriving
// where a placeholder or nothing stood — so none may be folded into the next.
override fun canCoalesce(): Boolean = false

override fun getEventData(): WritableMap = Arguments.createMap().apply {
putString("appearance", appearance)
putBoolean("animated", isRevealAnimated)
putInt("revealDurationMs", if (isRevealAnimated) revealDurationMs else 0)
}

companion object {
const val EVENT_NAME = "topAppearanceChange"
}
}

internal class BlockLoadEvent(surfaceId: Int, viewTag: Int) : Event<BlockLoadEvent>(surfaceId, viewTag) {
/**
* How the load ended: `load`, `empty` or `fail`, the reason going with a failure. An event payload
* has every field, so a missing reason is spelled as an empty string.
*/
internal class BlockOutcomeEvent(
surfaceId: Int,
viewTag: Int,
private val outcome: String,
private val reason: String?,
) : Event<BlockOutcomeEvent>(surfaceId, viewTag) {
Comment thread
Vailence marked this conversation as resolved.
override fun getEventName(): String = EVENT_NAME

override fun getEventData(): WritableMap = Arguments.createMap()
// Every outcome is promised to the host once it changes; two in one frame must both arrive.
override fun canCoalesce(): Boolean = false

companion object {
const val EVENT_NAME = "topBlockLoad"
override fun getEventData(): WritableMap = Arguments.createMap().apply {
putString("outcome", outcome)
putString("reason", reason.orEmpty())
}
}

internal class BlockFailEvent(surfaceId: Int, viewTag: Int) : Event<BlockFailEvent>(surfaceId, viewTag) {
override fun getEventName(): String = EVENT_NAME

override fun getEventData(): WritableMap = Arguments.createMap()

companion object {
const val EVENT_NAME = "topBlockFail"
const val EVENT_NAME = "topBlockOutcome"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,27 @@ import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import cloud.mindbox.mobile_sdk.Mindbox
import cloud.mindbox.mobile_sdk.annotations.InternalMindboxApi
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockAppearance
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockFailReason
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockListener
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockLoadingStrategy
import cloud.mindbox.mobile_sdk.embedded.MindboxEmbeddedBlockView
import cloud.mindbox.mobile_sdk.logger.Level

@OptIn(InternalMindboxApi::class)
internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(context) {
var onAppearance: ((String) -> Unit)? = null
/** The appearance word, whether this change is the SDK's animated reveal, and how long it takes. */
var onAppearance: ((appearance: String, isRevealAnimated: Boolean, revealDurationMs: Int) -> Unit)? = null

var onOutcome: ((String) -> Unit)? = null
/** The outcome word and, for a failure, the reason's raw value. */
var onOutcome: ((outcome: String, reason: String?) -> Unit)? = null

private var blockView: MindboxEmbeddedBlockView? = null
private var placeSystemName: String? = null
private var timeoutMs: Long? = null
private var loadingStrategy: MindboxEmbeddedBlockLoadingStrategy = MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC
/** A strategy word this SDK did not know, kept to be logged once the block is built and the place is known. */
private var unknownLoadingStrategyWord: String? = null
private var animatesReveal: Boolean = true
private var hostVisible: Boolean = true
private var hasPlaceholder: Boolean = false
private var hasErrorView: Boolean = false
Expand Down Expand Up @@ -83,6 +90,26 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont
}
}

// Fixed at creation, as the timeout is: the JS side warns the host about a later value rather
// than applying it, and the native block takes both only through its constructor. A word this
// SDK does not know is read as `automatic` and logged when the block is built — Fabric sets the
// props in no fixed order, and here the place name may not have arrived yet.
fun setLoadingStrategy(word: String?) {
if (blockView != null) {
return
}

val strategy = EmbeddedBlockWire.loadingStrategyOf(word)
unknownLoadingStrategyWord = if (strategy == null) word else null
loadingStrategy = strategy ?: MindboxEmbeddedBlockLoadingStrategy.AUTOMATIC
}

fun setAnimatesReveal(animatesReveal: Boolean) {
if (blockView == null) {
this.animatesReveal = animatesReveal
}
}

fun setHostVisible(isHostVisible: Boolean) {
if (hostVisible == isHostVisible) {
return
Expand Down Expand Up @@ -132,24 +159,50 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont
logLevel = Level.ERROR,
)
}
unknownLoadingStrategyWord?.let { word ->
Mindbox.writeLog(
message = "[EmbeddedBlock] A React Native block for place '$place' was given a loading strategy this SDK does not know ('$word') and starts as automatic",
logLevel = Level.ERROR,
)
}

val block = MindboxEmbeddedBlockView(context, place, timeoutMs)
val block = MindboxEmbeddedBlockView(
context = context,
placeSystemName = place,
timeoutMs = timeoutMs,
loadingStrategy = loadingStrategy,
animatesReveal = animatesReveal,
Comment thread
Vailence marked this conversation as resolved.
)
blockView = block

syncStandIns()
block.setHostVisible(hostVisible)
block.setListener(
object : MindboxEmbeddedBlockListener {
override fun onLoad(view: MindboxEmbeddedBlockView) {
onOutcome?.invoke(OUTCOME_LOAD)
onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_LOAD, null)
}

override fun onFail(view: MindboxEmbeddedBlockView) {
onOutcome?.invoke(OUTCOME_FAIL)
override fun onEmpty(view: MindboxEmbeddedBlockView) {
onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_EMPTY, null)
}

override fun onFail(view: MindboxEmbeddedBlockView, reason: MindboxEmbeddedBlockFailReason) {
onOutcome?.invoke(EmbeddedBlockWire.OUTCOME_FAIL, reason.value)
}
},
)
block.setAppearanceObserver { appearance -> onAppearance?.invoke(nameOf(appearance)) }
// `isRevealAnimated` is read while the observer runs: the block sets it right before it
// calls, for that one call. The block owns the gates — `animatesReveal`, a window to animate
// in, animations enabled on the device, "only the arrival of content" — and this host only
// passes its word on; the growth of a block that waited hidden is then the JS side's.
block.setAppearanceObserver { appearance ->
onAppearance?.invoke(
EmbeddedBlockWire.nameOf(appearance),
block.isRevealAnimated,
MindboxEmbeddedBlockView.REVEAL_ANIMATION_DURATION_MS.toInt(),
)
}

addView(block, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
Expand Down Expand Up @@ -202,16 +255,4 @@ internal class MindboxEmbeddedBlockHostView(context: Context) : FrameLayout(cont
isClickable = false
isFocusable = false
}

private companion object {
const val OUTCOME_LOAD = "load"
const val OUTCOME_FAIL = "fail"

fun nameOf(appearance: MindboxEmbeddedBlockAppearance): String = when (appearance) {
MindboxEmbeddedBlockAppearance.PLACEHOLDER -> "placeholder"
MindboxEmbeddedBlockAppearance.CONTENT -> "content"
MindboxEmbeddedBlockAppearance.ERROR -> "error"
MindboxEmbeddedBlockAppearance.COLLAPSED -> "collapsed"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ internal class MindboxEmbeddedBlockViewManager :

override fun addEventEmitters(reactContext: ThemedReactContext, view: MindboxEmbeddedBlockHostView) {
super.addEventEmitters(reactContext, view)
view.onAppearance = { appearance ->
dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance) }
view.onAppearance = { appearance, isRevealAnimated, revealDurationMs ->
dispatch(view) { surfaceId, tag -> AppearanceChangeEvent(surfaceId, tag, appearance, isRevealAnimated, revealDurationMs) }
}
view.onOutcome = { outcome ->
dispatch(view) { surfaceId, tag ->
if (outcome == OUTCOME_LOAD) BlockLoadEvent(surfaceId, tag) else BlockFailEvent(surfaceId, tag)
}
view.onOutcome = { outcome, reason ->
dispatch(view) { surfaceId, tag -> BlockOutcomeEvent(surfaceId, tag, outcome, reason) }
}
}

Expand Down Expand Up @@ -60,6 +58,14 @@ internal class MindboxEmbeddedBlockViewManager :
view.setTimeoutMs(value)
}

override fun setLoadingStrategy(view: MindboxEmbeddedBlockHostView, value: String?) {
view.setLoadingStrategy(value)
}

override fun setAnimatesReveal(view: MindboxEmbeddedBlockHostView, value: Boolean) {
view.setAnimatesReveal(value)
}

override fun setHasPlaceholder(view: MindboxEmbeddedBlockHostView, value: Boolean) {
view.setHasPlaceholder(value)
}
Expand All @@ -74,8 +80,7 @@ internal class MindboxEmbeddedBlockViewManager :

override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> = mutableMapOf(
AppearanceChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onAppearanceChange"),
BlockLoadEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockLoad"),
BlockFailEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockFail"),
BlockOutcomeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onBlockOutcome"),
)

private inline fun dispatch(
Expand All @@ -89,6 +94,5 @@ internal class MindboxEmbeddedBlockViewManager :

internal companion object {
const val NAME = "MindboxEmbeddedBlockView"
const val OUTCOME_LOAD = "load"
}
}
Loading
Loading