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. *