diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f9412875..c85da50e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -585,6 +585,20 @@
Taken up by the group into the list.
Copy the suggestion
Copied the suggestion
+
+ Broadcast
+ Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours.
+ Where the group has said it lives is filled in. Add or remove relays before sending.
+ No relays to broadcast to. Add one above.
+ Add a relay to broadcast to.
+ Sending…
+ Accepted
+ Rejected: %1$s
+ Couldn't send: %1$s
+ No answer
+ Accepted by %1$s of %2$s relays.
+ Copy raw JSON
+ Copied the raw JSON
A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.
An npub is a public key. Mantra needs the secret key, the nsec, to sign as you.
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 eaa2fffb..e2876be2 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
@@ -28,14 +28,19 @@ import press.mantra.compose.nostr.frost.GroupKeyStateEvent
*
* ### Why the whole event, and not a pointer to one
*
- * There is nothing to point at. A group-signed event never travels on the wire
- * as itself -- the outbound pipeline re-authors rumors as their sender and would
- * strip the signature off -- so it is not a `NostrEvent`, and it is not a
- * `MarmotInnerEvent` either, since no member sent it. Every device derives it
+ * There is nothing to point at. A group-signed event never travels between
+ * members as itself -- the outbound pipeline re-authors rumors as their sender
+ * and would strip the signature off -- so it is not a `NostrEvent`, and it is not
+ * a `MarmotInnerEvent` either, since no member sent it. Every device derives it
* from a signing session it took part in, or is handed it in a chronicle. The
* 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
+ * `BroadcastGroupSignedEventViewModel` for why a row would be the wrong shape.
+ *
* ### The derivation path
*
* [publicKey] is the group's threshold key walked to [derivationPath], and for a
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
index 01acecfd..40f4f4ef 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
@@ -183,6 +183,13 @@ class DatabaseChatRepository(
emptyList()
}
+ override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try {
+ database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId }
+ } catch (e: Throwable) {
+ logger.e("Error reading the signed event $id of $chatRoomId", e)
+ null
+ }
+
override suspend fun canSign(chatRoomId: String): Boolean = try {
FrostSigningManager.canSign(database, chatRoomId)
} catch (e: Throwable) {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
index bf049bee..5dfa9ce4 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt
@@ -925,9 +925,11 @@ object FrostSigningManager {
// signature by now, so each applies the result itself rather than
// waiting to be sent something it can already build -- the same
// reasoning the transcript lines are written on. Nothing goes on the
- // wire: a signed event authored by the threshold key cannot travel as
- // an inner event anyway, because the outbound pipeline re-authors
- // rumors as their sender and would strip the group's signature off.
+ // wire from here: a signed event authored by the threshold key cannot
+ // travel as an inner event anyway, because the outbound pipeline
+ // re-authors rumors as their sender and would strip the group's
+ // signature off. Sending one to relays is a member's separate act,
+ // from `BroadcastGroupSignedEventScreen`, off the recorded row.
signedEvents.forEach { applySignedEvent(database, session, it, recorded) }
announce(
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt
new file mode 100644
index 00000000..a15fd5db
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt
@@ -0,0 +1,32 @@
+package press.mantra.compose.network.relays
+
+import kotlinx.coroutines.flow.Flow
+import press.mantra.compose.database.model.NostrEvent
+import press.mantra.compose.network.dto.RelayDTO
+
+/**
+ * The slice of the relay layer a one-off publish needs.
+ *
+ * The same cut [LiveSubscriptionTransport] makes and for the same reason:
+ * [RelaysSocketManager] starts collecting relay lists in its `init` and cannot be
+ * stood up in a test, so a screen that hands it an event would otherwise have no
+ * way to check what it does with the answers.
+ *
+ * Everything the app publishes as *itself* goes through the
+ * `BroadcastNostrEventRequest` queue instead, which is durable and retried. This
+ * exists for the one thing that must not: an event the group signed. The queue
+ * hangs off a `NostrEvent` row, and a row for the group's event would put its post
+ * into the home feed and offer it to every relay the sync loop reconciles with --
+ * see `BroadcastGroupSignedEventViewModel` for the whole of that argument.
+ */
+interface EventPublishTransport {
+
+ /**
+ * Sends [nostrEvent] to each of [relays], opening a socket to any it does not
+ * already hold, and answers once per relay: the relay's OK, its rejection, or
+ * the error that stopped it being asked.
+ *
+ * @see RelayPool.publishEvent
+ */
+ suspend fun publishEvent(nostrEvent: NostrEvent, relays: Set): Flow
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt
index 392b0b7d..7d796b02 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt
@@ -32,7 +32,7 @@ class RelaysSocketManager(
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
private val relayRepository: press.mantra.compose.repository.RelayRepository,
-) : LiveSubscriptionTransport {
+) : LiveSubscriptionTransport, EventPublishTransport {
val logger = Logger.withTag("RelaysSocketManager")
private val scope = CoroutineScope(Dispatchers.IO)
private val relayPoolsMutex = Mutex()
@@ -116,7 +116,7 @@ class RelaysSocketManager(
}
@Throws(press.mantra.compose.exceptions.NostrPublishException::class)
- suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow {
+ override suspend fun publishEvent(nostrEvent: press.mantra.compose.database.model.NostrEvent, relays: Set): Flow {
logger.d("publishEvent: $nostrEvent")
return relayPool.publishEvent(
nostrEvent = nostrEvent,
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt
index 536b21f1..f66c75d7 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupNostrProfile.kt
@@ -25,11 +25,13 @@ import kotlin.time.Instant
* events in that position. So the group's profile lives where the group's other
* signed statements live, and this is the reading of it.
*
- * The consequence worth stating: **nothing here has reached a relay.** A member's
- * kind:0 is published and is how the rest of nostr learns their name; a group's is
- * signed and kept, and every device that followed the signing session -- or was
- * handed the event afterwards -- holds it. Publishing it would mean a `NostrEvent`
- * row for an event no member authored, which is a decision this does not make.
+ * The consequence worth stating: **nothing here reaches a relay by being signed.**
+ * A member's kind:0 is published and is how the rest of nostr learns their name; a
+ * group's is signed and kept, and every device that followed the signing session
+ * -- or was handed the event afterwards -- holds it. It reaches a relay when a
+ * member sends it there from `BroadcastGroupSignedEventScreen`, which serialises
+ * the signed row directly rather than making a `NostrEvent` row for an event no
+ * member authored -- a decision this still does not make.
*
* ### What a reading has to earn
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt
index 82e31bef..9ddbbfe3 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupRelayList.kt
@@ -30,11 +30,12 @@ import com.vitorpamplona.quartz.nip17Dm.settings.tags.RelayTag as Nip17RelayTag
* reasons `GroupNostrProfile` gives at length: a group-signed event is not a
* `NostrEvent`, and only an event the room itself authored is the group speaking.
*
- * ### Nothing here has reached a relay either
+ * ### Nothing here reaches a relay by being signed
*
* The lists say where the group's work *should* go; sending it there is a separate
- * job this does not do. See `GroupNostrProfile`, which carries the same caveat for
- * the same reason.
+ * act, taken by a member one event at a time from `BroadcastGroupSignedEventScreen`,
+ * and the General list is what that screen fills its relay picker from. See
+ * `GroupNostrProfile`, which carries the same caveat for the same reason.
*
* ### Public tags only, and not by preference
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 7ac07fa4..686f8091 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -125,6 +125,17 @@ interface ChatRepository {
*/
suspend fun groupCuratedSchemas(chatRoomId: String): List
+ /**
+ * One event the group signed, as the group signed it, or null when this
+ * device does not hold it or it was signed in some other room.
+ *
+ * Scoped to the room rather than looked up by id alone, because the id
+ * arrives as a navigation argument and the screen it opens names a room in
+ * its title: an event from one room shown under another room's name would
+ * be a lie the signature check cannot catch, since both rooms' events verify.
+ */
+ suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent?
+
/**
* Whether this device holds a share of the group's key, and so could take
* part in signing for it.
@@ -317,6 +328,11 @@ interface ChatRepository {
chatRoomId: String
): List = emptyList()
+ override suspend fun groupSignedEvent(
+ chatRoomId: String,
+ id: HexKey
+ ): GroupSignedEvent? = null
+
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun refuseSubgroup(
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
new file mode 100644
index 00000000..7314fbfa
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt
@@ -0,0 +1,627 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CellTower
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Dns
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.BottomAppBarDefaults
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.accepted_by_n_of_m_relays
+import mantra.composeapp.generated.resources.add_a_relay_to_broadcast_to
+import mantra.composeapp.generated.resources.add_relay
+import mantra.composeapp.generated.resources.broadcast
+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
+import mantra.composeapp.generated.resources.signed_by_the_group_on
+import mantra.composeapp.generated.resources.that_is_not_a_relay_address
+import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
+import mantra.composeapp.generated.resources.the_signed_event
+import mantra.composeapp.generated.resources.where_the_group_has_said_it_lives_is_filled_in
+import org.jetbrains.compose.resources.stringResource
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.network.relays.EventPublishTransport
+import press.mantra.compose.nostr.GroupRelayList
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.text.ProposedEvent
+import press.mantra.compose.ui.composable.widgets.Decorative
+import press.mantra.compose.ui.composable.widgets.EmptyState
+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.rememberNotifier
+import press.mantra.compose.ui.theme.ConformancePreviews
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.theme.readableContent
+import press.mantra.compose.ui.theme.spacing
+import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel
+import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
+import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
+import press.mantra.compose.extensions.toFormattedTimeAndDateString
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlin.time.Instant
+
+/**
+ * One event the group signed, and the relays to send it to.
+ *
+ * The group's screen shows what the group has signed; this is where one of those
+ * leaves the device. The list starts at where the group has said it lives and the
+ * member can change it, the button sends to every relay in it at once, and each
+ * row then says what its relay answered -- accepted, rejected in the relay's own
+ * words, or nothing. The raw JSON is at the bottom for anyone who would rather
+ * hand it to some other tool.
+ *
+ * **The button sends rather than proposes.** Every other screen off the identity
+ * block opens a signing session, because what it makes is a statement by the
+ * group. This one moves a statement the group has already made, which any member
+ * holding it may do: the signature is the group's, and no share of the key is
+ * needed to repeat it. See `BroadcastGroupSignedEventViewModel` for why the send
+ * goes to the pool directly rather than through the app's broadcast queue.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun BroadcastGroupSignedEventScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ signedEventId: HexKey,
+ initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState =
+ BroadcastGroupSignedEventUIState.Loading,
+ chatRepository: ChatRepository,
+ publishTransport: EventPublishTransport,
+ onNavigateBack: () -> Unit,
+) {
+ val broadcastGroupSignedEventViewModel: BroadcastGroupSignedEventViewModel = viewModel(
+ factory = BroadcastGroupSignedEventViewModel.factory(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ signedEventId = signedEventId,
+ initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState,
+ chatRepository = chatRepository,
+ publishTransport = publishTransport,
+ )
+ )
+
+ val clipboardManager = LocalClipboardManager.current
+
+ // Read out here rather than in a click handler: both are composable and an
+ // onClick lambda is not.
+ val notify = rememberNotifier(rememberCoroutineScope())
+ val noRelays = stringResource(Res.string.add_a_relay_to_broadcast_to)
+ val copied = stringResource(Res.string.copied_the_raw_json)
+
+ ScreenStateTransition(broadcastGroupSignedEventViewModel.broadcastGroupSignedEventUIState) { uiState ->
+ when (val broadcastGroupSignedEventUIState = uiState) {
+ is BroadcastGroupSignedEventUIState.Error -> {
+ // Nothing to retry: the room and the event came from navigation
+ // arguments, and reading them again with the same ones fails the
+ // same way.
+ ErrorState(message = broadcastGroupSignedEventUIState.message, onRetry = null)
+ }
+
+ is BroadcastGroupSignedEventUIState.Loaded -> {
+ val signedEvent = broadcastGroupSignedEventUIState.signedEvent
+ val urlFieldState = rememberTextFieldState()
+ val isPending = broadcastGroupSignedEventViewModel.isActionPending.value
+ val relays = broadcastGroupSignedEventViewModel.relays
+
+ // The list comes from the state rather than from whatever loaded
+ // it, so a screen handed a loaded state -- a preview, a layout
+ // test -- fills in the same as the app does. Once only, so a list
+ // the member emptied stays empty.
+ LaunchedEffect(broadcastGroupSignedEventUIState) {
+ broadcastGroupSignedEventViewModel.seedRelays(
+ relayLists = broadcastGroupSignedEventUIState.relayLists,
+ kind = signedEvent.kind,
+ tags = signedEvent.tags,
+ content = signedEvent.content,
+ )
+ }
+
+ // A refusal is about what is in the field, so it goes the moment
+ // the field changes rather than sitting under a URL that has since
+ // been corrected.
+ LaunchedEffect(urlFieldState) {
+ snapshotFlow { urlFieldState.text.toString() }
+ .collect { broadcastGroupSignedEventViewModel.clearAddFailure() }
+ }
+
+ // As the group signed it, byte for byte: what is copied is exactly
+ // what goes on the wire, because the point of copying it is to
+ // hand somebody something they can verify.
+ val json = signedEvent.toEvent().toJson()
+ val summary = ProposedEvent.summarize(signedEvent.toEvent())
+
+ // M3 gives a FAB no `enabled`, so borrow the disabled colours every
+ // other button in the app uses rather than inventing a shade here.
+ val buttonColors = ButtonDefaults.buttonColors()
+ val canSend = !isPending
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
+ modifier = Modifier.imePadding(),
+ topBar = {
+ TopAppBar(
+ title = {
+ broadcastGroupSignedEventUIState.localChatRoom.RenderChatRoomTitleText()
+ },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ },
+ bottomBar = {
+ BottomAppBar(
+ actions = {},
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ modifier = if (canSend) {
+ Modifier
+ } else {
+ // Looking unavailable is not being unavailable.
+ Modifier.semantics { disabled() }
+ },
+ containerColor = if (canSend) {
+ FloatingActionButtonDefaults.containerColor
+ } else {
+ buttonColors.disabledContainerColor
+ },
+ contentColor = if (canSend) {
+ contentColorFor(FloatingActionButtonDefaults.containerColor)
+ } else {
+ buttonColors.disabledContentColor
+ },
+ onClick = {
+ if (!canSend) return@ExtendedFloatingActionButton
+
+ broadcastGroupSignedEventViewModel.broadcast(
+ signedEvent = signedEvent,
+ // Stays on the screen: an empty list is
+ // a question rather than a mistake.
+ onNoRelays = { notify(noRelays) }
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.CellTower,
+ contentDescription = "Broadcast to these relays"
+ )
+ Text(stringResource(Res.string.broadcast))
+ }
+ }
+ )
+ }
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
+ .padding(horizontal = MaterialTheme.spacing.screenMargin),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
+ ) {
+ // What is being sent, in the words the signing screen used
+ // for it: a member deciding where a post goes is deciding
+ // about a post, and "kind 1" answers a question nobody asked.
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ .padding(top = MaterialTheme.spacing.containerPadding),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ Text(
+ text = summary.label,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = summary.detail,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 4
+ )
+
+ Text(
+ text = stringResource(
+ Res.string.signed_by_the_group_on,
+ signedEvent.createdAt.toFormattedTimeAndDateString()
+ ),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ item {
+ Text(
+ text = stringResource(
+ Res.string.sends_the_event_exactly_as_the_group_signed_it
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
+
+ Text(
+ text = stringResource(Res.string.relays),
+ style = MaterialTheme.typography.titleSmall
+ )
+
+ Text(
+ text = stringResource(
+ Res.string.where_the_group_has_said_it_lives_is_filled_in
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.weight(1f)
+ .background(BottomAppBarDefaults.containerColor),
+ state = urlFieldState,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent,
+ disabledBorderColor = Color.Transparent
+ ),
+ leadingIcon = {
+ Icon(Icons.Default.Dns, contentDescription = Decorative)
+ },
+ label = { Text(stringResource(Res.string.relay_url)) },
+ placeholder = {
+ Text(stringResource(Res.string.eg_wss_relay_example_com))
+ },
+ isError = broadcastGroupSignedEventViewModel.addFailure != null
+ )
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
+
+ IconButton(
+ onClick = {
+ if (
+ broadcastGroupSignedEventViewModel.addRelay(
+ urlFieldState.text.toString()
+ )
+ ) {
+ urlFieldState.clearText()
+ }
+ }
+ ) {
+ Icon(
+ Icons.Default.Add,
+ contentDescription = stringResource(Res.string.add_relay)
+ )
+ }
+ }
+
+ // Named rather than silent. An add button that does
+ // nothing for a URL it refuses is indistinguishable from
+ // a missed tap.
+ broadcastGroupSignedEventViewModel.addFailure?.let { failure ->
+ Text(
+ text = when (failure) {
+ BroadcastGroupSignedEventViewModel.AddFailure.NotARelay ->
+ stringResource(Res.string.that_is_not_a_relay_address)
+ BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed ->
+ stringResource(
+ Res.string.that_relay_is_already_in_this_list
+ )
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ // The last press, summed up, above the rows that break it
+ // down. Persistent rather than a snackbar, because it is
+ // the answer the member came for and a snackbar would be
+ // gone before the last relay had been read.
+ broadcastGroupSignedEventViewModel.lastBroadcast?.let { last ->
+ item {
+ Text(
+ text = stringResource(
+ Res.string.accepted_by_n_of_m_relays,
+ last.accepted,
+ last.asked
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (last.accepted == last.asked) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.error
+ }
+ )
+ }
+ }
+
+ if (relays.isEmpty()) {
+ item {
+ EmptyState(
+ message = stringResource(Res.string.no_relays_to_broadcast_to),
+ icon = Icons.Default.Dns
+ )
+ }
+ } else {
+ items(items = relays, key = { it.url }) { relay ->
+ BroadcastRelayRow(
+ relay = relay,
+ outcome = broadcastGroupSignedEventViewModel.outcomes[relay],
+ // A row cannot leave while its relay is being
+ // asked: the answer would arrive for a relay
+ // that is no longer there to show it.
+ canRemove = !isPending,
+ onRemove = {
+ broadcastGroupSignedEventViewModel.removeRelay(relay)
+ }
+ )
+ }
+ }
+
+ item {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
+
+ Text(
+ text = stringResource(Res.string.the_signed_event),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap))
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = json,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(MaterialTheme.spacing.containerPadding)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
+
+ FilledTonalButton(
+ onClick = {
+ clipboardManager.setText(AnnotatedString(json))
+ notify(copied)
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.ContentCopy, contentDescription = Decorative)
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
+ Text(text = stringResource(Res.string.copy_raw_json))
+ }
+
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
+ }
+ }
+ }
+ }
+
+ BroadcastGroupSignedEventUIState.Loading -> {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
+
+ Text(
+ text = stringResource(Res.string.broadcast),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center
+ )
+
+ LoadingDataIndicator(fillScreen = false)
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ if (initialBroadcastGroupSignedEventUIState == BroadcastGroupSignedEventUIState.Loading) {
+ broadcastGroupSignedEventViewModel.initiateBroadcastGroupSignedEvent()
+ }
+ }
+}
+
+/**
+ * One relay to send to, and what it said the last time it was asked.
+ *
+ * The answer is on a second line under the host rather than in an icon, because
+ * the answers that matter are the relay's own words -- "blocked: not on the
+ * allow list" is something a member can act on, and a red icon is not.
+ */
+@Composable
+private fun BroadcastRelayRow(
+ relay: NormalizedRelayUrl,
+ outcome: RelayOutcome?,
+ canRemove: Boolean,
+ onRemove: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = MaterialTheme.spacing.relatedGap),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ Text(
+ // The host rather than the whole URL: every relay here starts
+ // `wss://` -- nothing else is allowed in -- so the scheme is four
+ // characters of nothing in front of the part that differs.
+ text = relay.displayUrl(),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ 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)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = when (it) {
+ RelayOutcome.Accepted -> MaterialTheme.colorScheme.primary
+ is RelayOutcome.Rejected, is RelayOutcome.Failed ->
+ MaterialTheme.colorScheme.error
+ RelayOutcome.Sending, RelayOutcome.NoAnswer ->
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+ }
+
+ IconButton(onClick = onRemove, enabled = canRemove) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Remove relay",
+ // The button's own disabled colour while a send is out, rather
+ // than a red that promises a tap will do something.
+ tint = if (canRemove) MaterialTheme.colorScheme.error else LocalContentColor.current
+ )
+ }
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun BroadcastGroupSignedEventScreenPreview() {
+ val chatRoomId = "d4c3b2a1".repeat(8)
+
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ BroadcastGroupSignedEventScreen(
+ activeUserPublicKey = "",
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ signedEventId = "b".repeat(64),
+ initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = "",
+ subject = "Translation room",
+ description = "A group translating hard books.",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = "state"
+ ),
+ ),
+ signedEvent = GroupSignedEvent(
+ id = "b".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = TextNoteEvent.KIND,
+ tags = emptyArray(),
+ content = "The first chapter is out.",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000)
+ ),
+ // Nothing agreed, so the list fills in from the app's own
+ // publish set -- the state a group meets this screen in
+ // before it has edited its relays.
+ relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId),
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ publishTransport = object : EventPublishTransport {
+ override suspend fun publishEvent(
+ nostrEvent: press.mantra.compose.database.model.NostrEvent,
+ relays: Set
+ ): Flow = emptyFlow()
+ },
+ onNavigateBack = {}
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index 0f2483a7..65e4ea70 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -1,6 +1,9 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -25,6 +28,7 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Campaign
+import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.DataObject
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Draw
@@ -79,6 +83,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
+import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
@@ -170,6 +175,7 @@ import mantra.composeapp.generated.resources.this_room_signs_as
import mantra.composeapp.generated.resources.derivation_path
import mantra.composeapp.generated.resources.key_ceremony
import mantra.composeapp.generated.resources.agreed_on
+import mantra.composeapp.generated.resources.broadcast
import mantra.composeapp.generated.resources.the_signed_event
import mantra.composeapp.generated.resources.copy_the_signed_event
import mantra.composeapp.generated.resources.copied_the_signed_event
@@ -184,7 +190,7 @@ import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
import press.mantra.compose.ui.theme.ConformancePreviews
import kotlin.time.Instant
-@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ChatRoomDetailScreen(
activeUserPublicKey: HexKey,
@@ -240,6 +246,19 @@ fun ChatRoomDetailScreen(
) {
var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) }
+ // The one way into the broadcast screen, for every signed
+ // event in the identity block below.
+ val broadcast: (GroupSignedEvent) -> Unit = { signedEvent ->
+ onNavigateToRoute.invoke(
+ BroadcastGroupSignedEventRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ signedEventId = signedEvent.id
+ )
+ )
+ }
+
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -381,10 +400,29 @@ fun ChatRoomDetailScreen(
item {
chatRoomDetailUIState.groupNostrProfile?.let { groupNostrProfile ->
- GroupNostrProfileCard(
- groupNostrProfile = groupNostrProfile,
- publicKey = chatRoomId
- )
+ // The card and its broadcast button as one
+ // item, so the button stays under the event
+ // it sends however the list is arranged.
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ GroupNostrProfileCard(
+ groupNostrProfile = groupNostrProfile,
+ publicKey = chatRoomId
+ )
+
+ // Behind no gate, here and under every
+ // other signed event in the block: sending
+ // what the group has already signed takes
+ // no share of the key, since the
+ // signature on it is the group's whoever
+ // repeats it. Only *making* a statement
+ // is gated.
+ BroadcastButton(
+ onClick = { broadcast(groupNostrProfile.signedEvent) }
+ )
+ }
} ?: Text(
stringResource(
Res.string.this_group_has_not_said_anything_about_itself
@@ -439,7 +477,8 @@ fun ChatRoomDetailScreen(
item {
GroupRelayListsCard(
- relayLists = chatRoomDetailUIState.groupRelayLists
+ relayLists = chatRoomDetailUIState.groupRelayLists,
+ onBroadcast = broadcast
)
}
@@ -498,12 +537,21 @@ fun ChatRoomDetailScreen(
items = chatRoomDetailUIState.groupPosts,
key = { post -> post.signedEvent.id }
) { post ->
- GroupPostCard(
- post = post,
- publicKey = chatRoomId,
- groupNostrProfile =
- chatRoomDetailUIState.groupNostrProfile
- )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ GroupPostCard(
+ post = post,
+ publicKey = chatRoomId,
+ groupNostrProfile =
+ chatRoomDetailUIState.groupNostrProfile
+ )
+
+ BroadcastButton(
+ onClick = { broadcast(post.signedEvent) }
+ )
+ }
}
}
@@ -602,30 +650,48 @@ fun ChatRoomDetailScreen(
// strangers, and reading the answers
// takes no share of the key. Per card
// because each list has its own queue.
- TextButton(
- onClick = {
- onNavigateToRoute.invoke(
- CuratedSuggestionListRoute(
- activeUserPublicKey = activeUserPublicKey,
- chatRoomId = chatRoomId,
- relayHint = relayHint,
- identifier = curated.identifier
+ //
+ // Beside the broadcast button rather
+ // than over it, wrapping where the two
+ // will not fit on one line: both are
+ // about this one card and a column of
+ // buttons under every schema would
+ // read as a menu.
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.itemGap,
+ Alignment.CenterHorizontally
+ )
+ ) {
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ CuratedSuggestionListRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ identifier = curated.identifier
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.Default.Inbox,
+ contentDescription = Decorative
+ )
+
+ Spacer(
+ modifier = Modifier.width(
+ MaterialTheme.spacing.space125
)
)
+
+ Text(stringResource(Res.string.view_suggestions))
}
- ) {
- Icon(
- Icons.Default.Inbox,
- contentDescription = Decorative
- )
- Spacer(
- modifier = Modifier.width(
- MaterialTheme.spacing.space125
- )
+ BroadcastButton(
+ onClick = { broadcast(curated.signedEvent) }
)
-
- Text(stringResource(Res.string.view_suggestions))
}
}
}
@@ -1461,7 +1527,8 @@ internal fun GroupKeyStateSheetContent(
* The signing date is here because a group's profile is standing state with no
* other clue to its age -- and because the alternative reading, that this arrived
* from a relay a moment ago, is exactly what has not happened. Nothing on this card
- * has been published: see `GroupNostrProfile`.
+ * was published by being signed -- see `GroupNostrProfile` -- and the broadcast
+ * button under it is how it gets to a relay.
*/
@Composable
private fun GroupNostrProfileCard(
@@ -1537,7 +1604,10 @@ private fun GroupNostrProfileCard(
* deliberate withdrawal behind an oversight.
*/
@Composable
-private fun GroupRelayListsCard(relayLists: List) {
+private fun GroupRelayListsCard(
+ relayLists: List,
+ onBroadcast: (GroupSignedEvent) -> Unit
+) {
Card(modifier = Modifier.fillMaxWidth()) {
relayLists.forEach { list ->
ListItem(
@@ -1563,10 +1633,42 @@ private fun GroupRelayListsCard(relayLists: List) {
)
}
)
+
+ // Under the row it sends, and only where there is something signed
+ // to send: a list the group has never agreed is not an event. Inside
+ // the card because the four rows share one, so "under the event" has
+ // to mean under the row -- and at the end, where M3 puts a card's
+ // actions. An agreed empty list gets one too: a withdrawal is a
+ // statement, and relays still holding the old list need to hear it.
+ list.signedEvent?.let { signedEvent ->
+ Box(
+ modifier = Modifier.fillMaxWidth()
+ .padding(horizontal = MaterialTheme.spacing.compactPadding),
+ contentAlignment = Alignment.CenterEnd
+ ) {
+ BroadcastButton(onClick = { onBroadcast(signedEvent) })
+ }
+ }
}
}
}
+/**
+ * The way into `BroadcastGroupSignedEventScreen`, drawn the same under every
+ * signed event in the identity block so that it reads as one action wherever it
+ * sits. The icon is decorative because the label is beside it.
+ */
+@Composable
+private fun BroadcastButton(onClick: () -> Unit) {
+ TextButton(onClick = onClick) {
+ Icon(Icons.Default.CellTower, contentDescription = Decorative)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Text(stringResource(Res.string.broadcast))
+ }
+}
+
/** The name of a relay list, for the summary on the group's screen. */
private fun GroupRelaySet.summaryLabel() = when (this) {
GroupRelaySet.General -> Res.string.relay_set_general
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index 560951b9..62dc90ad 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
@@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
import press.mantra.compose.ui.composable.AddGroupPostScreen
import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
+import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen
import press.mantra.compose.ui.composable.CuratedSuggestionListScreen
import press.mantra.compose.ui.composable.ProposeGroupEventScreen
import press.mantra.compose.ui.composable.EditGroupRelaysScreen
@@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
+import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
@@ -1129,6 +1131,25 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ BroadcastGroupSignedEventScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ signedEventId = route.signedEventId,
+ chatRepository = databaseChatRepository,
+ // The one screen handed the pool itself rather than a queue
+ // to write to: a group-signed event must not become a
+ // `NostrEvent` row, and the queue hangs off one. See
+ // `BroadcastGroupSignedEventViewModel`.
+ publishTransport = synchronizationViewModel.relaysSocketManager,
+ onNavigateBack = {
+ navController.popBackStack()
+ }
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt
new file mode 100644
index 00000000..6e4cbaa1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt
@@ -0,0 +1,12 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BroadcastGroupSignedEventRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String, // TODO: have this as a publicKey
+ val relayHint: String?,
+ /** The `GroupSignedEvent` to send, which is also the event id the group signed. */
+ val signedEventId: String,
+): Route()
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
new file mode 100644
index 00000000..383a0ceb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt
@@ -0,0 +1,441 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.Kind
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.NostrEvent
+import press.mantra.compose.exceptions.NostrPublishException
+import press.mantra.compose.network.dto.RelayDTO
+import press.mantra.compose.network.relays.EventPublishTransport
+import press.mantra.compose.network.relays.NostrPublishResult
+import press.mantra.compose.network.relays.RelayPool
+import press.mantra.compose.network.sockets.NostrIncomingMessage
+import press.mantra.compose.nostr.GroupRelayList
+import press.mantra.compose.nostr.GroupRelaySet
+import press.mantra.compose.nostr.Relays
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * One event the group signed, sent to the relays a member picks.
+ *
+ * Signing and sending are two acts here, on purpose. `FrostSigningManager.complete`
+ * files what a quorum signed and applies it locally, and nothing leaves the device;
+ * this is the second act, taken by a member, one event at a time, to relays they
+ * can see and change before pressing the button. What goes on the wire is the
+ * 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.
+ *
+ * ### Why not the broadcast queue
+ *
+ * Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
+ * which is durable, retried and drained in the background. It is not used here
+ * because it hangs off a `NostrEvent` row, and a row for the group's event is
+ * more than a queue entry: the home feed selects by kind, so the group's post
+ * would appear in it as if it were anybody's; and the sync loop offers every
+ * local event id to the relays it reconciles with, so the event would reach
+ * relays this screen never named. `GroupSignedEvent` explains at length why it is
+ * not a `NostrEvent`; this keeps that true. The cost is that a broadcast is only
+ * as durable as the button press -- what each relay said is shown, and a relay
+ * that did not answer is sent to again by pressing again.
+ *
+ * ### Where the relays come from
+ *
+ * [defaultRelaysFor]: the group's own General list where it has agreed one, the
+ * app's publish set where it has not, plus the indexers for a relay list and the
+ * list's own relays for a schema, minus anything the group has blocked. The
+ * member can add and remove before sending, and the seeded list is a suggestion
+ * rather than a rule.
+ */
+class BroadcastGroupSignedEventViewModel(
+ val chatRoomId: String,
+ val signedEventId: HexKey,
+ val activeUserPublicKey: HexKey,
+ val relayHint: String?,
+ initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState,
+ val chatRepository: ChatRepository,
+ val publishTransport: EventPublishTransport,
+): ViewModel() {
+
+ var broadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState by mutableStateOf(
+ initialBroadcastGroupSignedEventUIState
+ )
+ private set
+
+ private val logger = Logger.withTag(TAG)
+
+ val isActionPending: MutableState = mutableStateOf(false)
+
+ /**
+ * The relays the event will be sent to, in the order they are shown.
+ *
+ * Held on the view model rather than in the composition, like the relay
+ * editor's working copy, so a rotation does not empty the list.
+ */
+ val relays = mutableStateListOf()
+
+ /**
+ * What each relay said the last time the button was pressed.
+ *
+ * Keyed by relay rather than kept as a list, because a relay removed after it
+ * answered takes its answer with it, and one added afterwards has none until
+ * the next press.
+ */
+ val outcomes = mutableStateMapOf()
+
+ /**
+ * Why the last add did not happen, or null. Cleared by the next keystroke.
+ *
+ * The same two refusals as the relay editor's, for the same reason: an add
+ * button that does nothing for a URL it refuses is indistinguishable from a
+ * missed tap.
+ */
+ var addFailure: AddFailure? by mutableStateOf(null)
+ private set
+
+ /**
+ * How the last press went, or null before the first one.
+ *
+ * Shown on the screen rather than flashed in a snackbar, because it is the
+ * answer the member came for and it should still be there after the rows
+ * under it have been read. Cleared when the next press starts.
+ */
+ var lastBroadcast: BroadcastSummary? by mutableStateOf(null)
+ private set
+
+ private var isSeeded = false
+
+ /** [accepted] of [asked] relays said OK. */
+ data class BroadcastSummary(val accepted: Int, val asked: Int)
+
+ enum class AddFailure {
+ /** Not a `wss://` URL, or one pointing at this machine. */
+ NotARelay,
+
+ /** Already in the list, which is a no-op rather than a mistake. */
+ AlreadyListed,
+ }
+
+ /** One relay's answer to one press of the button. */
+ sealed interface RelayOutcome {
+ /** Asked, and not yet answered. */
+ data object Sending : RelayOutcome
+
+ /** The relay's OK: it holds the event now, or already did. */
+ data object Accepted : RelayOutcome
+
+ /** The relay answered and said no, in its own words. */
+ data class Rejected(val reason: String) : RelayOutcome
+
+ /** The relay could not be asked: no connection, or one that dropped. */
+ data class Failed(val reason: String) : RelayOutcome
+
+ /** Asked, and still unanswered when the whole broadcast ran out of time. */
+ data object NoAnswer : RelayOutcome
+ }
+
+ fun initiateBroadcastGroupSignedEvent() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+ val signedEvent = chatRepository.groupSignedEvent(chatRoomId, signedEventId)
+
+ broadcastGroupSignedEventUIState = when {
+ localChatRoom == null ->
+ BroadcastGroupSignedEventUIState.Error("Couldn't find the chat room")
+
+ signedEvent == null ->
+ BroadcastGroupSignedEventUIState.Error(
+ "This device doesn't hold that signed event"
+ )
+
+ else -> BroadcastGroupSignedEventUIState.Loaded(
+ localChatRoom = localChatRoom,
+ signedEvent = signedEvent,
+ relayLists = chatRepository.groupRelayLists(chatRoomId),
+ )
+ }
+ }
+ }
+
+ /**
+ * Fills the list from what the group has said, once.
+ *
+ * Called from the screen rather than from the loader, for the reason the
+ * relay editor's `seedWorkingCopy` gives: a preview and a layout test hand a
+ * loaded state straight in. Once and only once, so a member who emptied the
+ * list on purpose does not find it refilled by a state the view model re-emits.
+ */
+ fun seedRelays(relayLists: List, kind: Kind, tags: Array>, content: String) {
+ if (isSeeded) return
+ isSeeded = true
+
+ relays.addAll(defaultRelaysFor(relayLists, kind, tags, content))
+ }
+
+ fun clearAddFailure() {
+ addFailure = null
+ }
+
+ /**
+ * Adds [url] to the list, or says why it did not.
+ *
+ * True when the row appeared, which is the caller's cue to clear the field.
+ */
+ fun addRelay(url: String): Boolean {
+ val relayUrl = GroupRelaySet.relayUrlOrNull(url) ?: run {
+ addFailure = AddFailure.NotARelay
+ return false
+ }
+
+ if (relayUrl in relays) {
+ addFailure = AddFailure.AlreadyListed
+ return false
+ }
+
+ relays.add(relayUrl)
+ addFailure = null
+ return true
+ }
+
+ fun removeRelay(relayUrl: NormalizedRelayUrl) {
+ relays.remove(relayUrl)
+ outcomes.remove(relayUrl)
+ }
+
+ /**
+ * 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.
+ *
+ * [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.
+ *
+ * Returns the send, for a caller that has to wait for it, and null when
+ * nothing was sent.
+ */
+ fun broadcast(
+ signedEvent: GroupSignedEvent,
+ onNoRelays: () -> Unit,
+ ): Job? {
+ // Guard against double submits. A second press while the first is out
+ // would answer each relay's OK twice and count it twice.
+ if (isActionPending.value) return null
+
+ val targets = relays.toList()
+ if (targets.isEmpty()) {
+ onNoRelays.invoke()
+ return null
+ }
+
+ isActionPending.value = true
+ lastBroadcast = null
+ 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
+ }
+
+ lastBroadcast = BroadcastSummary(
+ accepted = targets.count { outcomes[it] == RelayOutcome.Accepted },
+ asked = targets.size,
+ )
+
+ isActionPending.value = false
+ }
+ }
+
+ /**
+ * 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"
+
+ /**
+ * 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
+ * down forever. The same bound the queue drainer puts on one attempt.
+ */
+ val BROADCAST_TIMEOUT = (RelayPool.PUBLISH_TIMEOUT * 2).milliseconds
+
+ /**
+ * The event as the pool wants it, and nothing more.
+ *
+ * A `NostrEvent` built to be serialised and dropped -- it is never
+ * written to the table, which is the whole point of this screen sending
+ * directly rather than through the queue. The seven fields are the ones
+ * the id was hashed over, so what goes out is what the group signed.
+ */
+ fun wireEventOf(signedEvent: GroupSignedEvent): NostrEvent = NostrEvent(
+ id = signedEvent.id,
+ pubKey = signedEvent.publicKey,
+ kind = signedEvent.kind,
+ tags = signedEvent.tags,
+ content = signedEvent.content,
+ sig = signedEvent.signature,
+ createdAt = signedEvent.createdAt,
+ )
+
+ /**
+ * Where an event of [kind] should go, before the member has said otherwise.
+ *
+ * The group's General list is the answer NIP-65 gives -- its write relays,
+ * where it has agreed any -- and the app's own publish set stands in for a
+ * group that has not, since a list of nothing would make the button a
+ * no-op on every group until somebody edits it. Two kinds add to that:
+ *
+ * - A relay list goes to the indexers as well. The list says where to find
+ * the group, so sending it only to the relays it names is circular -- a
+ * reader who already knew those relays would not need the list.
+ * - A curated schema goes to the relays it names for its own replies, so a
+ * client reading suggestions there finds what they are replying to.
+ *
+ * And nothing goes to a relay the group has blocked, whatever the other
+ * lists say; that is what a blocked list is for.
+ */
+ fun defaultRelaysFor(
+ relayLists: List,
+ kind: Kind,
+ tags: Array> = emptyArray(),
+ content: String = "",
+ ): List {
+ val general = relayLists.firstOrNull { it.set == GroupRelaySet.General }
+ ?.relays.orEmpty()
+ .filter { it.write }
+ .map { it.url }
+
+ val where = general.ifEmpty { Relays.eventPublishRelaySet.toList() }
+
+ val indexers = if (kind in GroupRelaySet.KINDS) {
+ Relays.DefaultIndexerRelayList.toList()
+ } else {
+ emptyList()
+ }
+
+ val schemaRelays = if (kind == CuratedSchemaEvent.KIND) {
+ runCatching { CuratedSchemaEvent.read(tags, content).relays }
+ .getOrDefault(emptyList())
+ .mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
+ } else {
+ emptyList()
+ }
+
+ val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked }
+ ?.relays.orEmpty()
+ .map { it.url }
+ .toSet()
+
+ return (where + indexers + schemaRelays).distinct().filterNot { it in blocked }
+ }
+
+ fun factory(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ signedEventId: HexKey,
+ initialBroadcastGroupSignedEventUIState: BroadcastGroupSignedEventUIState =
+ BroadcastGroupSignedEventUIState.Loading,
+ chatRepository: ChatRepository,
+ publishTransport: EventPublishTransport,
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ BroadcastGroupSignedEventViewModel(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ signedEventId = signedEventId,
+ initialBroadcastGroupSignedEventUIState = initialBroadcastGroupSignedEventUIState,
+ chatRepository = chatRepository,
+ publishTransport = publishTransport,
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt
new file mode 100644
index 00000000..2f7437d1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt
@@ -0,0 +1,26 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.nostr.GroupRelayList
+
+sealed interface BroadcastGroupSignedEventUIState {
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+ /** The event to send, as the group signed it. What goes on the wire is this, byte for byte. */
+ val signedEvent: GroupSignedEvent,
+ /**
+ * Where the group has said it lives, which is what the relay picker starts
+ * from: the General list's write relays when the group has agreed one, and
+ * never a relay the group has blocked. See
+ * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`.
+ */
+ val relayLists: List = emptyList(),
+ ): BroadcastGroupSignedEventUIState
+
+ data class Error(
+ val message: String
+ ): BroadcastGroupSignedEventUIState
+
+ data object Loading: BroadcastGroupSignedEventUIState
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt
new file mode 100644
index 00000000..327f695d
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt
@@ -0,0 +1,240 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.runDesktopComposeUiTest
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.NostrEvent
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.exceptions.NostrPublishException
+import press.mantra.compose.network.dto.RelayDTO
+import press.mantra.compose.network.relays.EventPublishTransport
+import press.mantra.compose.network.relays.NostrPublishResult
+import press.mantra.compose.network.sockets.NostrIncomingMessage
+import press.mantra.compose.nostr.GroupRelay
+import press.mantra.compose.nostr.GroupRelayList
+import press.mantra.compose.nostr.GroupRelaySet
+import press.mantra.compose.nostr.Relays
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
+import kotlin.test.Test
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+
+/**
+ * What the broadcast screen shows before, and after, the button.
+ *
+ * Before: the event named as the signing screen would name it, the relays the
+ * group has said it lives at already in the list, and a way to add one. After:
+ * each relay's answer under its own row, in the relay's words where it gave
+ * any, and the sum above them -- because the sum is what the member came for and
+ * the words are what they can act on.
+ */
+@OptIn(ExperimentalTestApi::class)
+class BroadcastGroupSignedEventScreenJvmTest {
+
+ private val chatRoomId = "d4c3b2a1".repeat(8)
+
+ private val one = RelayUrlNormalizer.normalize("wss://relay.one.example")
+ private val two = RelayUrlNormalizer.normalize("wss://relay.two.example")
+
+ /** A pool that answers as told and never opens a socket. */
+ private class FakeTransport(
+ private val answers: List
+ ) : EventPublishTransport {
+ override suspend fun publishEvent(
+ nostrEvent: NostrEvent,
+ relays: Set
+ ): Flow = flowOf(*answers.toTypedArray())
+ }
+
+ @Test
+ fun `the event is named, the group's relays are filled in, and the JSON is there to copy`() =
+ render(state()) {
+ onNodeWithText("Post").assertIsDisplayed()
+ onNodeWithText("The first chapter is out.").assertIsDisplayed()
+
+ // The group's own General list, as hosts.
+ onNodeWithText("relay.one.example").assertIsDisplayed()
+ onNodeWithText("relay.two.example").assertIsDisplayed()
+
+ onNodeWithText("Broadcast").assertIsDisplayed()
+ onNodeWithText("Copy raw JSON").assertIsDisplayed()
+
+ // The relays come before the JSON: the list is the screen's job and
+ // the JSON is for whoever would rather use a different tool.
+ val relays = onNodeWithText("relay.one.example").getBoundsInRoot().top
+ val json = onNodeWithText("The signed event").getBoundsInRoot().top
+ assertTrue(relays < json, "the JSON climbed above the relay list")
+ }
+
+ @Test
+ fun `a relay can be added, and a bad one is refused by name`() = render(state()) {
+ onNodeWithText("Relay url").performTextInput("wss://relay.three.example")
+ onNodeWithContentDescription("Add relay").performClick()
+ onNodeWithText("relay.three.example").assertIsDisplayed()
+
+ onNodeWithText("Relay url").performTextInput("not a relay")
+ onNodeWithContentDescription("Add relay").performClick()
+ onNodeWithText("That isn't a relay address.", substring = true).assertIsDisplayed()
+ }
+
+ @Test
+ fun `after the button, each relay says what it answered and the sum sits above them`() =
+ render(
+ uiState = state(),
+ transport = FakeTransport(
+ listOf(
+ NostrPublishResult(
+ result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true),
+ relayUrl = one.url,
+ ),
+ NostrPublishResult(
+ result = NostrIncomingMessage.OkMessage(
+ eventId = "b".repeat(64),
+ success = false,
+ message = "blocked: not on the allow list",
+ ),
+ error = NostrPublishException(message = "blocked: not on the allow list"),
+ relayUrl = two.url,
+ ),
+ )
+ )
+ ) {
+ onNodeWithContentDescription("Broadcast to these relays").performClick()
+
+ waitUntil(timeoutMillis = 5_000) {
+ onAllNodesWithText("Accepted by 1 of 2 relays.").fetchSemanticsNodes().isNotEmpty()
+ }
+
+ onNodeWithText("Accepted").assertIsDisplayed()
+ onNodeWithText("Rejected: blocked: not on the allow list").assertIsDisplayed()
+
+ // The answer under the relay it came from, not in a list of its own.
+ val relayOne = onNodeWithText("relay.one.example").getBoundsInRoot().top
+ val accepted = onNodeWithText("Accepted").getBoundsInRoot().top
+ val relayTwo = onNodeWithText("relay.two.example").getBoundsInRoot().top
+ val rejected = onNodeWithText("Rejected: blocked: not on the allow list").getBoundsInRoot().top
+ assertTrue(relayOne < accepted && accepted < relayTwo, "the OK is not under its relay")
+ assertTrue(relayTwo < rejected, "the rejection is not under its relay")
+ }
+
+ @Test
+ fun `an empty list asks nobody and says to add one`() = render(
+ state(relayLists = GroupRelayList.allAmong(emptyList(), chatRoomId), seedNothing = true)
+ ) {
+ onNodeWithText("No relays to broadcast to. Add one above.").assertIsDisplayed()
+ }
+
+ private fun signedEvent() = GroupSignedEvent(
+ id = "b".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = TextNoteEvent.KIND,
+ tags = emptyArray(),
+ content = "The first chapter is out.",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ )
+
+ /** A General list of two relays, agreed, so the screen has something of the group's to fill in. */
+ private fun agreedRelayLists(): List =
+ GroupRelayList.allAmong(emptyList(), chatRoomId).map { list ->
+ if (list.set != GroupRelaySet.General) return@map list
+
+ GroupRelayList(
+ set = list.set,
+ signedEvent = GroupSignedEvent(
+ id = "a".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = list.set.kind,
+ tags = emptyArray(),
+ content = "",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ ),
+ relays = listOf(GroupRelay(one), GroupRelay(two)),
+ )
+ }
+
+ /**
+ * [seedNothing] blocks every relay the seed would otherwise fall back to, so
+ * the screen can be seen with an empty list -- a group that has agreed
+ * nothing is filled in from the app's own set, which is the point of the
+ * fallback and the opposite of what this fixture wants.
+ */
+ private fun state(
+ relayLists: List = agreedRelayLists(),
+ seedNothing: Boolean = false,
+ ) = BroadcastGroupSignedEventUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = "a".repeat(64),
+ subject = "Translation room",
+ description = "A group translating hard books.",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = "state"
+ )
+ ),
+ signedEvent = signedEvent(),
+ relayLists = if (seedNothing) {
+ relayLists.map { list ->
+ if (list.set != GroupRelaySet.Blocked) return@map list
+
+ GroupRelayList(
+ set = list.set,
+ signedEvent = null,
+ relays = Relays.eventPublishRelaySet.map { GroupRelay(it) },
+ )
+ }
+ } else {
+ relayLists
+ },
+ )
+
+ private fun render(
+ uiState: BroadcastGroupSignedEventUIState,
+ transport: EventPublishTransport = FakeTransport(emptyList()),
+ assertions: ComposeUiTest.() -> Unit
+ ) = runDesktopComposeUiTest(width = 400, height = 1600) {
+ setContent {
+ MantraTheme {
+ ProvideSnackbarHost {
+ Box(modifier = Modifier.fillMaxSize()) {
+ BroadcastGroupSignedEventScreen(
+ activeUserPublicKey = "a".repeat(64),
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ signedEventId = "b".repeat(64),
+ initialBroadcastGroupSignedEventUIState = uiState,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ publishTransport = transport,
+ onNavigateBack = {}
+ )
+ }
+ }
+ }
+ }
+
+ assertions()
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
index b89d56eb..733dd80a 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
@@ -281,6 +281,57 @@ class GroupNostrProfileSectionJvmTest {
assertTrue(books.top < second.top, "the second queue button is not under its card")
}
+ @Test
+ fun `every signed event in the block offers a broadcast, under its own card`() = render(
+ state(
+ groupNostrProfile = profile,
+ relayLists = signedRelayLists(),
+ posts = listOf(post("Something the group said", 1_700_000_000)),
+ schemas = listOf(schema("films", "Films worth translating", 1_700_000_900))
+ )
+ ) {
+ // One per signed event: the profile, the two relay lists the group has
+ // agreed -- the withdrawn one included, since a withdrawal is a statement
+ // relays still holding the old list need to hear -- the post and the
+ // schema. Not the two lists the group has never agreed, which are not
+ // events.
+ val buttons = onAllNodesWithText("Broadcast")
+ buttons.assertCountEquals(5)
+
+ // Under the card it sends, in the order the block lays them out. The
+ // profile card is found by its address, since its name is also the
+ // post's byline.
+ val profileCard = onNodeWithText("group@example.com").getBoundsInRoot().top
+ val relays = onNodeWithText("Relays").getBoundsInRoot().top
+ val general = onNodeWithText("General").getBoundsInRoot().top
+ val blocked = onNodeWithText("Blocked").getBoundsInRoot().top
+ val postCard = onNodeWithText("Something the group said").getBoundsInRoot().top
+ val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top
+ val tops = (0 until 5).map { buttons[it].getBoundsInRoot().top }
+
+ assertTrue(profileCard < tops[0] && tops[0] < relays, "the profile's broadcast is not under its card")
+ assertTrue(general < tops[1] && tops[1] < blocked, "the general list's broadcast is not under its row")
+ assertTrue(blocked < tops[2] && tops[2] < postCard, "the blocked list's broadcast is not under its row")
+ assertTrue(postCard < tops[3] && tops[3] < schemaCard, "the post's broadcast is not under its card")
+ assertTrue(schemaCard < tops[4], "the schema's broadcast is not under its card")
+ }
+
+ @Test
+ fun `a member holding no share of the key may still broadcast what the group signed`() = render(
+ state(
+ groupNostrProfile = profile,
+ relayLists = signedRelayLists(),
+ posts = listOf(post("Something the group said", 1_700_000_000)),
+ canEditNostrProfile = false
+ )
+ ) {
+ // Sending what is already signed takes no share of the key -- the
+ // signature is the group's whoever repeats it -- so this is the one
+ // action in the block, with the queue, that the gate does not cover.
+ onAllNodesWithText("Broadcast").assertCountEquals(4)
+ onNodeWithText("Edit Nostr profile").assertDoesNotExist()
+ }
+
@Test
fun `a member holding a share of the key may propose any event the block can show`() = render(
state(groupNostrProfile = profile, relayLists = signedRelayLists())
@@ -342,6 +393,7 @@ class GroupNostrProfileSectionJvmTest {
onNodeWithText("Curated schemas").assertDoesNotExist()
onNodeWithText("Add schema").assertDoesNotExist()
onNodeWithText("Propose event").assertDoesNotExist()
+ onAllNodesWithText("Broadcast").assertCountEquals(0)
// The rest of the screen is untouched, so the section's absence is an
// absence rather than a screen that failed to draw.
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt
new file mode 100644
index 00000000..64b36d13
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt
@@ -0,0 +1,370 @@
+package press.mantra.compose.ui.view.model
+
+import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
+import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
+import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import kotlinx.serialization.json.jsonPrimitive
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.NostrEvent
+import press.mantra.compose.exceptions.NostrPublishException
+import press.mantra.compose.network.dto.RelayDTO
+import press.mantra.compose.network.relays.EventPublishTransport
+import press.mantra.compose.network.relays.NostrPublishResult
+import press.mantra.compose.network.sockets.NostrIncomingMessage
+import press.mantra.compose.nostr.GroupRelay
+import press.mantra.compose.nostr.GroupRelayList
+import press.mantra.compose.nostr.GroupRelaySet
+import press.mantra.compose.nostr.Relays
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome
+import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+
+/**
+ * The three decisions the broadcast screen makes that nothing else can check.
+ *
+ * **Where an event goes before anyone says otherwise.** The seeded list is what a
+ * member who reads nothing and presses the button sends to, so it has to be the
+ * group's own answer where the group has given one, has to leave out what the
+ * group has blocked, and has to widen for the two kinds whose readers would not
+ * otherwise find them.
+ *
+ * **What each relay's answer becomes.** The pool answers once per relay in three
+ * shapes -- an OK, a rejection wrapped in one exception type, and any other error
+ * -- and the rows show them as three different things because a member can act
+ * on "blocked: not on the allow list" and cannot act on a red icon.
+ *
+ * **What goes on the wire.** The seven fields the group hashed and nothing else,
+ * so a relay checking the id and the signature finds the event the group signed.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class BroadcastGroupSignedEventViewModelJvmTest {
+ private val chatRoomId = "d4c3b2a1".repeat(8)
+
+ private val one = RelayUrlNormalizer.normalize("wss://relay.one.example")
+ private val two = RelayUrlNormalizer.normalize("wss://relay.two.example")
+ private val three = RelayUrlNormalizer.normalize("wss://relay.three.example")
+
+ /** Answers each relay with whatever [answers] says, keyed by host, and remembers what it was handed. */
+ private class FakeTransport(
+ private val answers: Map
+ ) : EventPublishTransport {
+ var sent: NostrEvent? = null
+ var askedRelays: Set = emptySet()
+
+ override suspend fun publishEvent(
+ nostrEvent: NostrEvent,
+ relays: Set
+ ): Flow {
+ sent = nostrEvent
+ askedRelays = relays
+ return flowOf(*answers.values.toTypedArray())
+ }
+ }
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun viewModel(transport: EventPublishTransport = FakeTransport(emptyMap())) =
+ BroadcastGroupSignedEventViewModel(
+ chatRoomId = chatRoomId,
+ signedEventId = "b".repeat(64),
+ activeUserPublicKey = "a".repeat(64),
+ relayHint = null,
+ initialBroadcastGroupSignedEventUIState = BroadcastGroupSignedEventUIState.Loading,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ publishTransport = transport,
+ )
+
+ private fun signedEvent(kind: Int = TextNoteEvent.KIND, tags: Array> = emptyArray()) =
+ GroupSignedEvent(
+ id = "b".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = kind,
+ tags = tags,
+ content = "The first chapter is out.",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ )
+
+ private fun relaySignedEvent(set: GroupRelaySet) = GroupSignedEvent(
+ id = "a" + set.kind.toString().padStart(5, '0') + "0".repeat(58),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = set.kind,
+ tags = emptyArray(),
+ content = "",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ )
+
+ /** Every set unsigned, which is a group that has never said where it lives. */
+ private fun nothingAgreed() = GroupRelayList.allAmong(emptyList(), chatRoomId)
+
+ private fun agreed(vararg lists: Pair>) =
+ nothingAgreed().map { list ->
+ lists.firstOrNull { it.first == list.set }
+ ?.let { GroupRelayList(list.set, relaySignedEvent(list.set), it.second) }
+ ?: list
+ }
+
+ private fun ok(relay: NormalizedRelayUrl) = NostrPublishResult(
+ result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = true),
+ relayUrl = relay.url,
+ )
+
+ private fun rejected(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult(
+ result = NostrIncomingMessage.OkMessage(eventId = "b".repeat(64), success = false, message = reason),
+ error = NostrPublishException(message = reason),
+ relayUrl = relay.url,
+ )
+
+ private fun failed(relay: NormalizedRelayUrl, reason: String) = NostrPublishResult(
+ error = IllegalStateException(reason),
+ relayUrl = relay.url,
+ )
+
+ // -- where an event goes before anyone says otherwise ------------------------
+
+ @Test
+ fun `a group that has not said where it lives sends to the app's publish set`() {
+ assertEquals(
+ Relays.eventPublishRelaySet.toList(),
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(nothingAgreed(), TextNoteEvent.KIND),
+ )
+ }
+
+ @Test
+ fun `a group's own general list wins, and only the relays it writes to`() {
+ val relayLists = agreed(
+ GroupRelaySet.General to listOf(
+ GroupRelay(one),
+ // Read-only: where the group looks, not where it puts things.
+ GroupRelay(two, read = true, write = false),
+ )
+ )
+
+ // The group's answer and nothing from the app's set beside it: a member
+ // who edited the relays meant them, and padding the list would send the
+ // group's post somewhere the group decided not to be.
+ assertEquals(
+ listOf(one),
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND),
+ )
+ }
+
+ @Test
+ fun `a blocked relay is dropped whatever the other lists say`() {
+ val relayLists = agreed(
+ GroupRelaySet.General to listOf(GroupRelay(one), GroupRelay(two)),
+ GroupRelaySet.Blocked to listOf(GroupRelay(two)),
+ )
+
+ assertEquals(
+ listOf(one),
+ BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND),
+ )
+ }
+
+ @Test
+ fun `a relay list also goes to the indexers, since its readers do not yet know the relays it names`() {
+ val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
+
+ val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor(
+ relayLists, AdvertisedRelayListEvent.KIND
+ )
+
+ assertEquals(one, seeded.first())
+ Relays.DefaultIndexerRelayList.forEach { indexer ->
+ assertTrue(indexer in seeded, "the relay list would not reach ${indexer.url}")
+ }
+
+ // And a post does not: nobody looks an indexer up for a kind 1.
+ val post = BroadcastGroupSignedEventViewModel.defaultRelaysFor(relayLists, TextNoteEvent.KIND)
+ Relays.DefaultIndexerRelayList.forEach { indexer ->
+ assertFalse(indexer in post, "a post was sent to the indexer ${indexer.url}")
+ }
+ }
+
+ @Test
+ fun `a curated schema also goes to the relays it names for its own replies`() {
+ val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
+
+ val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor(
+ relayLists = relayLists,
+ kind = CuratedSchemaEvent.KIND,
+ tags = arrayOf(
+ arrayOf("d", "films"),
+ arrayOf("relay", three.url),
+ ),
+ content = "Films worth translating.",
+ )
+
+ // The group's relay first, then the list's own -- a client reading
+ // suggestions on the list's relay finds the schema they answer.
+ assertEquals(listOf(one, three), seeded)
+ }
+
+ @Test
+ fun `seeding happens once, so an emptied list stays empty`() {
+ val viewModel = viewModel()
+ val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one)))
+ val event = signedEvent()
+
+ viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
+ viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
+ assertEquals(listOf(one), viewModel.relays.toList())
+
+ viewModel.removeRelay(one)
+ viewModel.seedRelays(relayLists, event.kind, event.tags, event.content)
+ assertEquals(emptyList(), viewModel.relays.toList())
+ }
+
+ // -- what may be added ----------------------------------------------------------
+
+ @Test
+ fun `an add is refused by name, and a good one clears the refusal`() {
+ val viewModel = viewModel()
+
+ assertFalse(viewModel.addRelay("https://not-a-relay.example"))
+ assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.NotARelay, viewModel.addFailure)
+
+ assertTrue(viewModel.addRelay(one.url))
+ assertNull(viewModel.addFailure)
+
+ assertFalse(viewModel.addRelay(one.url))
+ assertEquals(BroadcastGroupSignedEventViewModel.AddFailure.AlreadyListed, viewModel.addFailure)
+
+ assertEquals(listOf(one), viewModel.relays.toList())
+ }
+
+ // -- what each relay's answer becomes ---------------------------------------------
+
+ @Test
+ fun `each relay's answer lands on its own row, and the sum counts only the OKs`() {
+ val transport = FakeTransport(
+ mapOf(
+ "one" to ok(one),
+ "two" to rejected(two, "blocked: not on the allow list"),
+ "three" to failed(three, "Connection refused"),
+ )
+ )
+ val viewModel = viewModel(transport)
+ listOf(one, two, three).forEach { assertTrue(viewModel.addRelay(it.url)) }
+
+ var toldNoRelays = false
+ val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true })
+ runBlocking { withTimeout(5_000) { job!!.join() } }
+
+ assertFalse(toldNoRelays)
+ assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one])
+ assertEquals(RelayOutcome.Rejected("blocked: not on the allow list"), viewModel.outcomes[two])
+ assertEquals(RelayOutcome.Failed("Connection refused"), viewModel.outcomes[three])
+ assertEquals(
+ BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 3),
+ viewModel.lastBroadcast,
+ )
+ assertFalse(viewModel.isActionPending.value)
+
+ // Asked as write relays: the pool would otherwise read from them too.
+ assertEquals(
+ setOf(one, two, three).map { RelayDTO(url = it.url, read = false, write = true) }.toSet(),
+ transport.askedRelays,
+ )
+ }
+
+ @Test
+ fun `an answer is matched by host, since the pool may spell the relay with a trailing slash`() {
+ val transport = FakeTransport(
+ mapOf("one" to ok(one).copy(relayUrl = one.url.trimEnd('/') + "/"))
+ )
+ val viewModel = viewModel(transport)
+ assertTrue(viewModel.addRelay(one.url))
+
+ val job = viewModel.broadcast(signedEvent(), onNoRelays = {})
+ runBlocking { withTimeout(5_000) { job!!.join() } }
+
+ assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one])
+ }
+
+ @Test
+ fun `a relay that never answers is marked so, which is not the same as refused`() {
+ // The pool answers for one relay and then the flow ends, which is what a
+ // send that ran out of time looks like from here.
+ val transport = FakeTransport(mapOf("one" to ok(one)))
+ val viewModel = viewModel(transport)
+ assertTrue(viewModel.addRelay(one.url))
+ assertTrue(viewModel.addRelay(two.url))
+
+ val job = viewModel.broadcast(signedEvent(), onNoRelays = {})
+ runBlocking { withTimeout(5_000) { job!!.join() } }
+
+ assertEquals(RelayOutcome.Accepted, viewModel.outcomes[one])
+ assertEquals(RelayOutcome.NoAnswer, viewModel.outcomes[two])
+ assertEquals(
+ BroadcastGroupSignedEventViewModel.BroadcastSummary(accepted = 1, asked = 2),
+ viewModel.lastBroadcast,
+ )
+ }
+
+ @Test
+ fun `an empty list asks nobody and says so`() {
+ val transport = FakeTransport(emptyMap())
+ val viewModel = viewModel(transport)
+
+ var toldNoRelays = false
+ val job = viewModel.broadcast(signedEvent(), onNoRelays = { toldNoRelays = true })
+
+ assertNull(job)
+ assertTrue(toldNoRelays)
+ assertNull(transport.sent)
+ assertFalse(viewModel.isActionPending.value)
+ }
+
+ // -- what goes on the wire ------------------------------------------------------------
+
+ @Test
+ fun `what goes on the wire is the event the group signed, field for field`() {
+ val event = signedEvent(
+ kind = MetadataEvent.KIND,
+ tags = arrayOf(arrayOf("alt", "The group's profile")),
+ )
+
+ val wire = BroadcastGroupSignedEventViewModel.wireEventOf(event).toNostrJsonObject()
+
+ assertEquals(event.id, wire["id"]!!.jsonPrimitive.content)
+ assertEquals(event.publicKey, wire["pubkey"]!!.jsonPrimitive.content)
+ assertEquals(event.createdAt.epochSeconds.toString(), wire["created_at"]!!.jsonPrimitive.content)
+ assertEquals(event.kind.toString(), wire["kind"]!!.jsonPrimitive.content)
+ assertEquals(event.content, wire["content"]!!.jsonPrimitive.content)
+ assertEquals(event.signature, wire["sig"]!!.jsonPrimitive.content)
+ assertEquals("""[["alt","The group's profile"]]""", wire["tags"].toString())
+ }
+}