From 39b1c714b82275e2fa338b747311e0076816d015 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 13:55:29 +0200 Subject: [PATCH] feat(groups): a broadcast button under each signed event, and the relays it goes to Until now nothing a group signed reached a relay. `FrostSigningManager.complete` files the batch as `GroupSignedEvent` rows and applies it locally, and the profile, the relay lists, the posts and the schemas are all read off those rows -- the docs on each said so, and the only way out was the copy button on the key-state sheet. This is the way out: a "Broadcast" button under every signed event in the identity block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits the relays it will go to, presses once, and reads what each relay answered. The raw JSON is at the bottom for anyone who would rather use another tool. **The send goes to the relay pool directly, not through the broadcast queue.** Everything the app publishes as itself goes through `BroadcastNostrEventRequest`, which is durable and retried, and it is not used here on purpose. The queue 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 anybody's; and the sync loop offers every local event id to the relays it reconciles with, so the event would reach relays the screen never named -- which would make "edit the relays" a fiction. `GroupSignedEvent` already explains at length why it is not a `NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of `RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is and for the same reason: the manager cannot be stood up in a test. The cost is that a broadcast is only as durable as the button press. Each row says what its relay said, and a relay that did not answer is sent to again by pressing again. **Behind no gate.** Every other button in the block is hidden from a member holding no share of the key, because each proposes a signature by the group. Sending what the group has already signed takes no share: the signature is the group's whoever repeats it, and any member could paste the JSON into another client today. So the button is there for whoever is looking, the way the suggestion queue is. **Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's General list where it has agreed one -- its write relays, and nothing from the app's set beside them, since a member who edited the relays meant them -- and the app's publish set where it has not. A relay list also goes to the indexers, because a list sent only to the relays it names is circular; a curated schema also goes to the relays it names for its own replies, so a client reading suggestions there finds what they answer. Nothing goes to a relay the group has blocked. The seed happens once, so a list the member emptied stays empty. **Each relay's answer is shown in the relay's words.** The pool answers once per relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and any other error -- and the rows keep them apart, because "blocked: not on the allow list" is something a member can act on and a red icon is not. A relay still unanswered when the whole send runs out of time reads "No answer", which is not the same as refused. Answers are matched by host, since the pool names a relay by the URL it first opened a socket with. The relay-list rows get their button inside the shared card, under the row and at the end, because the four rows share one card and "under the event" has to mean under the row. An agreed empty list gets one too: a withdrawal is a statement, and relays still holding the old list need to hear it. The key-state event and the subgroup certificates get none -- `ChronicleManager` keeps the key state off even the members-only path, and a public relay is a bigger audience than that. `BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only seeding, the three answer shapes and the host matching, and that what goes on the wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest` presses the button against a fake pool and finds each answer under its relay with the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group with a profile, two agreed lists, a post and a schema, each under its own card, and finds them still there for a member with no share. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@6ae5a9676eb01fcda49024caffff1a8bdc335831 --- .../composeResources/values/strings.xml | 14 + .../database/model/GroupSignedEvent.kt | 13 +- .../repository/DatabaseChatRepository.kt | 7 + .../compose/managers/FrostSigningManager.kt | 8 +- .../network/relays/EventPublishTransport.kt | 32 + .../network/relays/RelaysSocketManager.kt | 4 +- .../mantra/compose/nostr/GroupNostrProfile.kt | 12 +- .../mantra/compose/nostr/GroupRelayList.kt | 7 +- .../compose/repository/ChatRepository.kt | 16 + .../BroadcastGroupSignedEventScreen.kt | 627 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 168 ++++- .../ui/composable/navigation/MantraNavHost.kt | 21 + .../routes/BroadcastGroupSignedEventRoute.kt | 12 + .../BroadcastGroupSignedEventViewModel.kt | 441 ++++++++++++ .../state/BroadcastGroupSignedEventUIState.kt | 26 + .../BroadcastGroupSignedEventScreenJvmTest.kt | 240 +++++++ .../GroupNostrProfileSectionJvmTest.kt | 52 ++ ...oadcastGroupSignedEventViewModelJvmTest.kt | 370 +++++++++++ 18 files changed, 2020 insertions(+), 50 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/EventPublishTransport.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/BroadcastGroupSignedEventRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt 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()) + } +}