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()) + } +}