From b3379f8117f882810427d5853c28ac4bf43aa3e0 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 13:31:32 +0200 Subject: [PATCH] feat(groups): broadcast every accepted entry from one button, each to where its own would go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Pulled-From: curated/curated@d2a4298d256d419a54b5d0d75a6adda505a927db --- .../database/model/GroupSignedEvent.kt | 7 +- .../BroadcastGroupSignedEventScreen.kt | 16 +- .../composable/widgets/RelayOutcomeLabel.kt | 30 +++ .../BroadcastGroupSignedEventViewModel.kt | 188 +++++++++++------- 4 files changed, 151 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt index e2876be2..c1e70904 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupSignedEvent.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 411a5dcc..80bfd17c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt new file mode 100644 index 00000000..b756ac6c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/RelayOutcomeLabel.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt index c4b66f41..10828cc2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -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 = 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, 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, + onOutcome: (NormalizedRelayUrl, RelayOutcome) -> Unit = { _, _ -> }, + ): Map { + val outcomes = mutableMapOf() + + 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, + result: NostrPublishResult, + ): Pair? { + 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. *