feat(groups): broadcast every accepted entry from one button, each to where its own would go

The entries screen had one "Broadcast" under each card, and each opened
`BroadcastGroupSignedEventScreen` for that one entry: a group that has accepted
twenty entries into its lists sent them with twenty trips through the relay
picker. This is the other button. "Broadcast all", an extended FAB in a bottom
bar, sends every entry on the screen, newest first, one after another, and says
under each card what its relays answered. Behind no gate, like the button under
each card: sending what the group has already signed takes no share of the key.

**Each entry goes where its own broadcast screen would have started.** The list
that screen seeds -- `defaultRelaysFor`: the group's General write relays or the
app's publish set, plus the list's own relays, minus what the group has blocked
-- is what a member who reads nothing and presses the button sends to, and this
button is that member with twenty entries. The group's relay lists are read
into the loaded state for it; the list's relays come off the entry, which
carries its schema, so the rule is handed that one schema rather than all of
the group's. Nothing is shown before the press, which is the difference from
the one-event screen and the reason that screen stays: a refusal is answered
there, where the relay can be changed and the send tried again.

**One entry at a time, on purpose.** The rows fill in top to bottom, so the
member can see where the batch is; and a public relay sent twenty events in
one burst answers "rate-limited" to most of them, which would make the button
worse than the presses it saves. Each entry's send has the same ceiling as a
single broadcast and the batch has no other, since a long list is meant to
take a while. It goes to the pool directly rather than through the queue, for
the reason `BroadcastGroupSignedEventViewModel` gives, and it is only as
durable as the press: leaving the screen ends it where it is.

**The send is shared rather than copied.** What a relay's answer becomes -- an
OK, a rejection in the relay's words, a failure, or no answer when the whole
send runs out of time -- was decided inside `broadcast()` on the one-event view
model. It is now `sendToRelays` on that model's companion, beside
`defaultRelaysFor` and `wireEventOf`, and `broadcast()` keeps only the state
around it; the thirteen tests on it pass unchanged. The words for an answer
moved the same way, out of the relay row and into `RelayOutcome.label()`, which
both screens now use.

**What the member is told.** A line above the cards -- "Sending entry 3 of
12…", then "Accepted by every relay for 11 of 12 entries." -- persistent rather
than a snackbar, because it is the answer the member came for and a snackbar
would be gone before the last card had been read. Under each card, "Accepted
by n of m relays." in the one-event screen's words, and one line per relay
that did not say OK, named with its reason, because "blocked: not on the allow
list" is something a member can act on and a count of two out of three is not.
An entry with nowhere to go -- everything the seed would name blocked -- is
marked so rather than sent to nobody. The FAB looks disabled while a batch is
out, and a second press is refused.

`GroupCuratedEntriesViewModelJvmTest` pins where each entry goes, that the next
waits for the last to settle, what the answers become, and the refusals; the
screen test drives the button end to end and checks that each answer sits
between its card and that card's own broadcast. The shared fixture entry now
carries the `a` root a real entry has -- without it the seed rule could not
find the list's relays, which the first run of these tests found.

Verified with :composeApp:m3Audit (all budgets met),
:composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest (1085 tests,
7 new).

Replayed onto Mantra by docs/curated-to-mantra.md, in part: only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d2a4298d25
This commit is contained in:
Kgothatso Ngako
2026-09-13 13:31:32 +02:00
parent d7d6f5a922
commit b3379f8117
4 changed files with 151 additions and 90 deletions

View File

@@ -36,9 +36,10 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
* columns are `NostrEvent`'s so that what is stored is an event rather than a
* summary of one, which is what makes [verifies] answerable from the row alone.
*
* The one way one reaches a relay is a member sending it there by hand, from
* `BroadcastGroupSignedEventScreen`, and that path serialises the row straight
* to the pool without ever making a `NostrEvent` of it -- see
* The one way one reaches a relay is a member sending it there by hand -- from
* `BroadcastGroupSignedEventScreen`, or every accepted entry at once from the
* entries screen -- and that path serialises the row straight to the pool
* without ever making a `NostrEvent` of it -- see
* `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape.
*
* ### The derivation path

View File

@@ -69,11 +69,6 @@ import mantra.composeapp.generated.resources.copied_the_raw_json
import mantra.composeapp.generated.resources.copy_raw_json
import mantra.composeapp.generated.resources.eg_wss_relay_example_com
import mantra.composeapp.generated.resources.no_relays_to_broadcast_to
import mantra.composeapp.generated.resources.relay_accepted
import mantra.composeapp.generated.resources.relay_could_not_send
import mantra.composeapp.generated.resources.relay_no_answer
import mantra.composeapp.generated.resources.relay_rejected
import mantra.composeapp.generated.resources.relay_sending
import mantra.composeapp.generated.resources.relay_url
import mantra.composeapp.generated.resources.relays
import mantra.composeapp.generated.resources.sends_the_event_exactly_as_the_group_signed_it
@@ -95,6 +90,7 @@ import press.mantra.compose.ui.composable.widgets.ErrorState
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.composable.widgets.label
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.composable.widgets.NavigateBackButton
import press.mantra.compose.ui.theme.ConformancePreviews
@@ -533,15 +529,7 @@ private fun BroadcastRelayRow(
outcome?.let {
Text(
text = when (it) {
RelayOutcome.Sending -> stringResource(Res.string.relay_sending)
RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted)
is RelayOutcome.Rejected ->
stringResource(Res.string.relay_rejected, it.reason)
is RelayOutcome.Failed ->
stringResource(Res.string.relay_could_not_send, it.reason)
RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer)
},
text = it.label(),
style = MaterialTheme.typography.bodySmall,
color = when (it) {
RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary

View File

@@ -0,0 +1,30 @@
package press.mantra.compose.ui.composable.widgets
import androidx.compose.runtime.Composable
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.relay_accepted
import mantra.composeapp.generated.resources.relay_could_not_send
import mantra.composeapp.generated.resources.relay_no_answer
import mantra.composeapp.generated.resources.relay_rejected
import mantra.composeapp.generated.resources.relay_sending
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
/**
* One relay's answer to a broadcast, in the words both screens that show one
* use for it: the row under a relay on `BroadcastGroupSignedEventScreen`, and
* the line under an entry that "Broadcast all" sent.
*
* A rejection and a failure carry the relay's own reason, because "blocked:
* not on the allow list" is something a member can act on and a red icon is
* not. One spelling for both screens, so a relay's answer reads the same
* whichever button sent to it.
*/
@Composable
fun RelayOutcome.label(): String = when (this) {
RelayOutcome.Sending -> stringResource(Res.string.relay_sending)
RelayOutcome.Accepted -> stringResource(Res.string.relay_accepted)
is RelayOutcome.Rejected -> stringResource(Res.string.relay_rejected, reason)
is RelayOutcome.Failed -> stringResource(Res.string.relay_could_not_send, reason)
RelayOutcome.NoAnswer -> stringResource(Res.string.relay_no_answer)
}

View File

@@ -49,6 +49,11 @@ import kotlin.time.Duration.Companion.milliseconds
* event exactly as the group signed it -- the signature is the group's, so any
* member holding the event can send it and no share of the key is needed.
*
* The send itself, [sendToRelays], is also what the entries screen's "Broadcast
* all" uses, one entry after another and each to the relays [defaultRelaysFor]
* would have seeded here; what is this screen's is the list the member edits
* and the rows that show each relay's answer.
*
* ### Why not the broadcast queue
*
* Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
@@ -93,8 +98,6 @@ class BroadcastGroupSignedEventViewModel(
)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
@@ -235,17 +238,15 @@ class BroadcastGroupSignedEventViewModel(
/**
* Sends [signedEvent] to every relay in the list and records what each said.
*
* One call to the pool for the whole list, which opens a socket to any relay
* it does not hold and answers once per relay -- an OK, a rejection with the
* relay's reason, or the error that stopped it being asked. Each answer lands
* in [outcomes] as it arrives, so the rows fill in one by one rather than all
* at the end; a relay that has not answered when the whole thing runs out of
* time is marked so, which is different from having been refused.
* The send itself is [sendToRelays], which the entries screen's
* "Broadcast all" shares; what is this screen's is the state around it.
* Each answer lands in [outcomes] as it arrives, so the rows fill in one
* by one rather than all at the end, and [lastBroadcast] sums it up once
* every row has settled.
*
* [lastBroadcast] sums it up once every row has settled. [onNoRelays] instead
* of a send to nobody: an empty list is a question rather than a mistake, and
* the pool would otherwise fall back to the *member's* relays, which is not
* what an empty list on this screen means.
* [onNoRelays] instead of a send to nobody: an empty list is a question
* rather than a mistake, and the pool would otherwise fall back to the
* *member's* relays, which is not what an empty list on this screen means.
*
* Returns the send, for a caller that has to wait for it, and null when
* nothing was sent.
@@ -269,43 +270,13 @@ class BroadcastGroupSignedEventViewModel(
outcomes.clear()
targets.forEach { outcomes[it] = RelayOutcome.Sending }
val wireEvent = wireEventOf(signedEvent)
return viewModelScope.launch(Dispatchers.IO) {
try {
withTimeout(BROADCAST_TIMEOUT) {
publishTransport.publishEvent(
nostrEvent = wireEvent,
relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(),
)
// One answer per relay is all the pool ever gives, and the
// flow underneath never completes on its own.
.take(targets.size)
.collect { result -> record(targets, result) }
}
} catch (e: TimeoutCancellationException) {
// Ordinary: a relay that never answers is what the timeout is
// for. The rows still marked as sending are settled below.
logger.w("Broadcast of ${signedEvent.id} ran out of time")
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// The pool refused the whole call rather than one relay's part of
// it, so every row that was waiting gets the same answer.
logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e)
targets.forEach { relay ->
if (outcomes[relay] == RelayOutcome.Sending) {
outcomes[relay] = RelayOutcome.Failed(e.message ?: "Could not send")
}
}
}
targets.forEach { relay ->
if (outcomes[relay] == RelayOutcome.Sending) outcomes[relay] = RelayOutcome.NoAnswer
val settled = sendToRelays(publishTransport, signedEvent, targets) { relay, outcome ->
outcomes[relay] = outcome
}
lastBroadcast = BroadcastSummary(
accepted = targets.count { outcomes[it] == RelayOutcome.Accepted },
accepted = settled.count { it.value == RelayOutcome.Accepted },
asked = targets.size,
)
@@ -313,37 +284,11 @@ class BroadcastGroupSignedEventViewModel(
}
}
/**
* Files one relay's answer against the row that asked it.
*
* Matched by host rather than by string, because the pool names a relay by
* the URL it first opened a socket with, and that may carry a trailing slash
* this list's spelling does not.
*/
private fun record(targets: List<NormalizedRelayUrl>, result: NostrPublishResult) {
val answered = NormalizedRelayUrl(result.relayUrl).displayUrl()
val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run {
logger.w("Answer from ${result.relayUrl}, which was not asked")
return
}
val error = result.error
val ok = result.result as? NostrIncomingMessage.OkMessage
outcomes[relay] = when {
// The pool wraps a relay's ["OK", id, false, reason] in this one
// exception type and nothing else in it, so the message is the
// relay's reason in the relay's words.
error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected")
error != null -> RelayOutcome.Failed(error.message ?: "Could not send")
ok?.success == true -> RelayOutcome.Accepted
else -> RelayOutcome.Failed("No OK from the relay")
}
}
companion object {
private const val TAG = "BroadcastGroupSignedEventViewModel"
private val logger = Logger.withTag(TAG)
/**
* A ceiling on the whole broadcast, past the pool's own per-relay wait,
* so a socket that opens and then says nothing cannot hold the button
@@ -351,6 +296,103 @@ class BroadcastGroupSignedEventViewModel(
*/
val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds
/**
* Sends [signedEvent] to [targets] and says what each relay answered.
*
* One call to the pool for the whole list, which opens a socket to any
* relay it does not hold and answers once per relay -- an OK, a
* rejection with the relay's reason, or the error that stopped it being
* asked. Each answer goes to [onOutcome] as it arrives, so a caller
* showing rows can fill them in one by one rather than all at the end;
* a relay that has not answered when the whole thing runs out of time
* is marked so, which is different from having been refused, and one
* the pool refused to ask at all is marked failed with the pool's reason.
*
* Returns once every relay has settled, with the answers in the order
* [targets] gave them. Shared by the one-event screen and the entries
* screen's "Broadcast all", so that what a relay's answer becomes is
* decided in one place.
*/
suspend fun sendToRelays(
publishTransport: EventPublishTransport,
signedEvent: GroupSignedEvent,
targets: List<NormalizedRelayUrl>,
onOutcome: (NormalizedRelayUrl, RelayOutcome) -> Unit = { _, _ -> },
): Map<NormalizedRelayUrl, RelayOutcome> {
val outcomes = mutableMapOf<NormalizedRelayUrl, RelayOutcome>()
fun settle(relay: NormalizedRelayUrl, outcome: RelayOutcome) {
outcomes[relay] = outcome
onOutcome(relay, outcome)
}
try {
withTimeout(BROADCAST_TIMEOUT) {
publishTransport.publishEvent(
nostrEvent = wireEventOf(signedEvent),
relays = targets.map { RelayDTO(url = it.url, read = false, write = true) }.toSet(),
)
// One answer per relay is all the pool ever gives, and the
// flow underneath never completes on its own.
.take(targets.size)
.collect { result ->
outcomeOf(targets, result)?.let { (relay, outcome) -> settle(relay, outcome) }
}
}
} catch (e: TimeoutCancellationException) {
// Ordinary: a relay that never answers is what the timeout is
// for. The relays still unanswered are settled below.
logger.w("Broadcast of ${signedEvent.id} ran out of time")
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// The pool refused the whole call rather than one relay's part of
// it, so every relay that was waiting gets the same answer.
logger.e("Broadcast of ${signedEvent.id} failed before any relay answered", e)
targets.forEach { relay ->
if (relay !in outcomes) settle(relay, RelayOutcome.Failed(e.message ?: "Could not send"))
}
}
targets.forEach { relay ->
if (relay !in outcomes) settle(relay, RelayOutcome.NoAnswer)
}
return targets.associateWith { outcomes.getValue(it) }
}
/**
* One relay's answer, against the relay that was asked.
*
* Matched by host rather than by string, because the pool names a relay
* by the URL it first opened a socket with, and that may carry a
* trailing slash the caller's spelling does not. Null for an answer
* from a relay nobody asked, which is logged and dropped.
*/
private fun outcomeOf(
targets: List<NormalizedRelayUrl>,
result: NostrPublishResult,
): Pair<NormalizedRelayUrl, RelayOutcome>? {
val answered = NormalizedRelayUrl(result.relayUrl).displayUrl()
val relay = targets.firstOrNull { it.displayUrl() == answered } ?: run {
logger.w("Answer from ${result.relayUrl}, which was not asked")
return null
}
val error = result.error
val ok = result.result as? NostrIncomingMessage.OkMessage
return relay to when {
// The pool wraps a relay's ["OK", id, false, reason] in this one
// exception type and nothing else in it, so the message is the
// relay's reason in the relay's words.
error is NostrPublishException -> RelayOutcome.Rejected(error.message ?: "Rejected")
error != null -> RelayOutcome.Failed(error.message ?: "Could not send")
ok?.success == true -> RelayOutcome.Accepted
else -> RelayOutcome.Failed("No OK from the relay")
}
}
/**
* The event as the pool wants it, and nothing more.
*