From 26de13b0100df900c49f5c1a5ff5562451bcdf05 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 11 Sep 2026 16:58:59 +0200 Subject: [PATCH] feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group's identity block has a typed editor for each thing the group signs about itself -- the profile form, the post composer, the relay editor, the schema editor -- and each builds one kind of event. This is the untyped way in: an unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to the quorum like anything else, and filed by kind once it is signed. A pasted kind 0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind 10002 the general relay list. The entry is "Propose event", last in the identity block, behind the same share-holder gate as every button in it and absent from a NIP-17 room like the block itself. The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov repo prints exactly the kind 31889 event it would publish, id, pubkey, signature and all; until now the only way to get that list under a group's key was to retype it field by field into the editor. Now it is pasted, checked, and signed. **Where the event lands is decided by nothing in this change, and that is the design.** `FrostSigningManager.complete` files every signed event as a `GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists and schemas off those rows by kind, each reader verifying the room's signature for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the profile form does, because by the time either is signed there is no telling them apart. A second path -- a switch on kind that wrote the pasted event into the right place -- would have been a second reading of the same rows, and two readings of one signature drift. So the persistence half of this feature is a test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1, kind 31889 and kind 10002 each through a real FROST quorum signature, in the shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`, `GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out, under the group's key and coordinate rather than the pasted one. **What the paste says about its author and its time is ignored, and the screen says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`: what goes to `proposeSigning` is the kind, the tags and the content, and the session resolves the room's key, hashes the id over it and stamps the time it opens at -- which is what every typed editor does too. `created_at` in particular had to go. Every kind here is replaceable or addressable, so a pasted timestamp older than the group's last event would make the new one *lose* to the old on every reader and the proposal would look as though it had done nothing. The explanatory line under the byline names both facts, because a paste with a `pubkey` in it is the one case where a member could reasonably expect otherwise. **Only kinds the screen has a place for, and only events that place would accept -- checked before the quorum is asked, not after.** A signature is the most expensive thing this app does, and the readers refuse rather than repair: a kind 0 whose content is not a profile, a blank kind 1, a kind 31889 with no visibility would each be signed, filed and shown nowhere, with nothing to tell the member their quorum was spent on nothing. So `GroupEventProposal.read` makes every check a reader makes, on the paste, and refuses with the reader's own reason -- the schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of kinds with a section on the group's screen: 0, 1, the four relay lists and 31889. Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate or a key state event has invariants a paste cannot be trusted to meet and a place in the room's life that a form is not, and the nip30303 kinds have editors of their own. A long-form article is a perfectly good event the group could sign; it is refused because the line is "has somewhere to land", and the refusal names the kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing groups are entitled to do, and an empty relay list is accepted, since it is a withdrawal the relays card shows as "no relays" rather than as nothing -- the same two lines `GroupNostrProfile.of` and `GroupRelayList` already draw. **The preview is the signing screen's own words.** Above the paste is what the group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1 field" -- read live on every change, since a paste is a few kilobytes and one JSON parse is cheaper than a debounce that would hide the answer for a beat. It is `ProposedEvent.summarize` on the reading, not a description of this screen's own, so that what a member reads before proposing is word for word what every other member reads before signing. Two descriptions of one event would drift. **And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay lists.** The three features before this one added none for their kinds, so a member asked to sign the group's profile has been shown "Event of kind 0" over a line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A profile is now said by its name and what it says about itself, with "An empty profile" and "Unreadable profile" kept apart because a signer should know which; a post by its words, the same call the translated-passage arm makes; a relay list by which of the four it is and its hosts, or "No relays" for a withdrawal. `ProposedEventTest` is new and pins all of them, the schema arm from the last commit included, and that an unrecognised kind keeps its number -- refusing to describe an event is better than describing it wrongly. **The paste stays on every refusal.** A refused reading is drawn under the field and the button, pressed anyway, is answered rather than ignored; a failed proposal is a snackbar over the same paste. The JSON is the thing worth keeping, and a member who pasted two kilobytes of schema should not have to find them again because a visibility tag was missing. The field is set in a monospace face for the same reason: it is JSON, and a bracket that does not line up is what a member is looking for in it. `isActionPending` guards a double press, because a pasted kind 1 accumulates like any other post and a second session over the same paste is a second post on the record forever. **The byline is the group's**, as on the post composer and for its reason: whatever is pasted goes out in the group's name, and a paste naming some other `pubkey` goes out in the group's name anyway. 23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and what is dropped, junk named as junk, the accepted set, an unreadable profile against an empty one, a blank post, a schema refused with the reader's reasons, a relay list with or without relays -- and the four signed chains that are this commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer. `ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview, the two refusals and the inert button for a member with no share, and two more cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block and take it away from a member with no share and from a NIP-17 room. 1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@392b90b8d508b0faf1da8ef304c74eefca6aa049 --- .../composeResources/values/strings.xml | 17 + .../compose/nostr/GroupEventProposal.kt | 188 +++++++ .../mantra/compose/text/ProposedEvent.kt | 36 ++ .../ui/composable/ChatRoomDetailScreen.kt | 41 ++ .../EditGroupCuratedSchemaScreen.kt | 2 +- .../ui/composable/ProposeGroupEventScreen.kt | 465 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 + .../routes/ProposeGroupEventRoute.kt | 11 + .../view/model/ProposeGroupEventViewModel.kt | 158 ++++++ .../ui/view/state/ProposeGroupEventUIState.kt | 32 ++ .../compose/nostr/GroupEventProposalTest.kt | 338 +++++++++++++ .../mantra/compose/text/ProposedEventTest.kt | 105 ++++ .../GroupNostrProfileSectionJvmTest.kt | 24 + .../ProposeGroupEventScreenJvmTest.kt | 116 +++++ 14 files changed, 1554 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 16d2b935..14648578 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -559,4 +559,21 @@ Year Duration in seconds Number + Propose event + Paste an unsigned Nostr event for the group to sign. It is filed by kind: a profile (kind 0), a post (kind 1), a relay list, or a curated schema (kind 31889). + The group signs it, so it takes a quorum. The author is the group and the time is now, whatever the paste says about either. + This group has no shared key, so it cannot sign an event. Run a shared key ceremony first. + Couldn't ask the group to sign this event. + Unsigned event + {"kind": 1, "tags": [], "content": "…"} + What the group would sign + Kind %1$s + That isn't JSON. + An event is a JSON object with a kind, tags and content. + An event needs a kind. + Kind %1$s has nowhere to go on the group's screen. It can file kinds %2$s. + Tags must be a list of lists of strings. + Content must be text. + The content isn't a profile. A kind 0 carries a JSON object of profile fields. + This schema can't be signed as it is: diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt new file mode 100644 index 00000000..52c1ca3c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt @@ -0,0 +1,188 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.network.serialization.CommonJson +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull + +/** + * An unsigned event a member pasted for the group to sign, read and checked + * before a quorum is asked for it. + * + * The typed editors -- the profile form, the post composer, the relay and + * schema editors -- each build one kind of event and know what it needs. This + * is the untyped way in: the event as JSON, the way another tool prints it + * (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event + * it would publish), pasted whole. What comes out is the same thing the editors + * produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`, + * and it is filed the same way when the quorum signs: `FrostSigningManager.complete` + * records it as a `GroupSignedEvent` and the group's screen reads its profile, + * posts, relay lists and schemas straight off those by kind. + * + * ### What is ignored + * + * `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the + * session resolves the room's key and hashes the id over it -- and the time is + * when the session opens, which is what every typed editor does too. A pasted + * `created_at` in particular has to go: the kinds here are replaceable or + * addressable, so a stale one would make the new event *older* than the last and + * the proposal would look as though it did nothing. The screen says so. + * + * ### What is refused, and why before rather than after + * + * Only kinds the group's screen has a place for, and only events that place + * would accept. A signature is the most expensive thing this app does, and the + * readers refuse rather than repair: a kind 0 whose content is not a profile, + * a blank post, a schema with no visibility would each be signed, filed and + * shown nowhere, with nothing to tell the member their quorum was spent on + * nothing. So every check a reader makes is made here first, on the paste. + */ +object GroupEventProposal { + /** + * The kinds a pasted event may be: exactly the ones the group's screen reads. + * + * Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup + * certificate or a key state event has invariants a paste cannot be trusted + * to meet and a place in the room's life that a form is not, and the + * nip30303 kinds have editors of their own. The line is "has a section on the + * group's screen", because that is where a member goes to see it landed. + */ + val ACCEPTED_KINDS: Set = + setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS + + sealed interface Reading + + /** An event the group could sign and the screen could show. */ + class Ready( + val kind: Kind, + val tags: Array>, + val content: String, + ) : Reading { + /** + * The event as the signing screen will describe it, for a preview before + * the button is pressed. The id, author and signature are placeholders: + * `ProposedEvent.summarize` reads none of them. + */ + fun preview(): Event = Event( + id = "", + pubKey = "", + createdAt = 0, + kind = kind, + tags = tags, + content = content, + sig = "", + ) + } + + /** Why the paste cannot be proposed as it is. */ + sealed interface Refused : Reading { + data object NotJson : Refused + + /** JSON, but an array, a string, a number -- anything but an object. */ + data object NotAnObject : Refused + + data object NoKind : Refused + + data class KindNotAccepted(val kind: Kind) : Refused + + /** `tags` present but not a list of lists of strings. */ + data object TagsMalformed : Refused + + /** `content` present but not a string. */ + data object ContentNotText : Refused + + /** A kind 0 whose content will not parse as a profile. */ + data object ProfileUnreadable : Refused + + /** A kind 1 with nothing to say, which the posts reader would drop. */ + data object PostBlank : Refused + + /** A kind 31889 the schema reader would refuse, with its reasons. */ + data class SchemaUnusable(val problems: List) : Refused + } + + private const val KEY_KIND = "kind" + private const val KEY_TAGS = "tags" + private const val KEY_CONTENT = "content" + + /** The paste as something the group could sign, or the reason it is not. */ + fun read(json: String): Reading { + val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() + ?: return Refused.NotJson + + val obj = element as? JsonObject ?: return Refused.NotAnObject + + // A number or a numeric string: relays write the former and a hand + // types either, and there is nothing to be gained by refusing "1". + val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind + if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind) + + val tags = when (val raw = obj[KEY_TAGS]) { + null -> emptyArray() + else -> readTags(raw) ?: return Refused.TagsMalformed + } + + val content = when (val raw = obj[KEY_CONTENT]) { + null -> "" + else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: return Refused.ContentNotText + } + + return when (kind) { + // An empty kind 0 is a profile the group has wiped, which it is + // entitled to do; content that is not JSON is not a profile at all. + // The same line `GroupNostrProfile.of` draws. + MetadataEvent.KIND -> if (metadataOf(tags, content) == null) { + Refused.ProfileUnreadable + } else { + Ready(kind, tags, content) + } + + // The line `AddGroupPostViewModel.proposePost` draws, for the reason + // it gives: a blank post cannot be told from a broken one and, kind:1 + // not being replaceable, cannot be un-said. + TextNoteEvent.KIND -> if (content.isBlank()) { + Refused.PostBlank + } else { + Ready(kind, tags, content) + } + + CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems -> + if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems) + } + + // A relay list with no relay in it is a list the group has withdrawn, + // which the relays card shows as "no relays" rather than as nothing. + else -> Ready(kind, tags, content) + } + } + + private fun readTags(raw: JsonElement): Array>? { + val outer = raw as? JsonArray ?: return null + + return outer.map { tag -> + val inner = tag as? JsonArray ?: return null + inner.map { value -> + (value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null + }.toTypedArray() + }.toTypedArray() + } + + private fun metadataOf(tags: Array>, content: String) = MetadataEvent( + id = "", + pubKey = "", + createdAt = 0, + tags = tags, + content = content, + sig = "", + ).contactMetaData() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 523fca38..fe9c10f3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,7 +1,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -156,6 +159,39 @@ object ProposedEvent { ).joinToString(" · ") ) + // The group's own profile. Named by what the group would be called and + // what it would say about itself, which is the whole of what is being + // decided; the JSON in the content is the one thing a signer should not + // have to read to find that out. + MetadataEvent.KIND -> Summary( + label = "Nostr profile", + detail = MetadataEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).contactMetaData()?.let { metadata -> + listOfNotNull( + metadata.anyName()?.takeIf { it.isNotBlank() }, + metadata.about?.takeIf { it.isNotBlank() } + ).joinToString(" · ").ifEmpty { "An empty profile" } + } ?: "Unreadable profile" + ) + + // A post in the group's name. The words are what is being decided, so + // they are the detail rather than a count of them -- the same call the + // translated-passage arm makes. + TextNoteEvent.KIND -> Summary(label = "Post", detail = event.content) + + // One of the group's relay lists, shown as hosts: every entry in one + // starts wss://, so the scheme is the part that never tells two apart. + // An empty one is a list the group is withdrawing, and says so. + in GroupRelaySet.KINDS -> { + val set = GroupRelaySet.entries.first { it.kind == event.kind } + + Summary( + label = "${set.name} relay list", + detail = set.parse(event.tags).joinToString { it.displayUrl() }.ifEmpty { "No relays" } + ) + } + // A list the group would curate. A member signing this is agreeing to // take suggestions from whoever the visibility admits and to be the one // key that accepts them, so the summary is the list's name, who may 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 33dabc79..161f8bf7 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 @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.Ballot import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks @@ -78,6 +79,7 @@ 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.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -108,6 +110,7 @@ import mantra.composeapp.generated.resources.curated_schemas import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet import mantra.composeapp.generated.resources.add_schema import mantra.composeapp.generated.resources.fields_colon +import mantra.composeapp.generated.resources.propose_event import mantra.composeapp.generated.resources.posted_on import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet import mantra.composeapp.generated.resources.edit_relays @@ -614,6 +617,44 @@ fun ChatRoomDetailScreen( } } + // The untyped way into everything above it. Each + // section has a form that builds one kind of event; + // this takes any of those kinds as JSON, pasted from + // wherever it was made, and it lands in whichever + // section is its kind's once the quorum signs -- + // the same reading, off the same signed events. + // Last in the block because it is about the block + // rather than about any one section of it, and + // behind the same gate for the same reason. + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposeGroupEventRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.DataObject, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.propose_event)) + } + } + } + item { HorizontalDivider() } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt index b3cba3f7..be868d9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt @@ -875,7 +875,7 @@ private fun CuratedFieldType.label(): StringResource = when (this) { * sentence and the check cannot disagree about the number. */ @Composable -private fun CuratedSchemaProblem.message(): String = when (this) { +internal fun CuratedSchemaProblem.message(): String = when (this) { CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required) CuratedSchemaProblem.IdentifierTooLong -> stringResource( Res.string.the_identifier_must_be_at_most_n_characters, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt new file mode 100644 index 00000000..b82b529e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreen.kt @@ -0,0 +1,465 @@ +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.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupEventProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.text.ProposedEvent +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +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.profile.ProfileAvatar +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.ProposeGroupEventViewModel +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_post_needs_something_to_say +import mantra.composeapp.generated.resources.an_event_is_a_json_object +import mantra.composeapp.generated.resources.an_event_needs_a_kind +import mantra.composeapp.generated.resources.content_must_be_text +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_event +import mantra.composeapp.generated.resources.eg_kind_1_tags_content +import mantra.composeapp.generated.resources.kind_n +import mantra.composeapp.generated.resources.kind_n_has_nowhere_to_go_on_this_screen +import mantra.composeapp.generated.resources.paste_an_unsigned_event_for_the_group_to_sign +import mantra.composeapp.generated.resources.propose_event +import mantra.composeapp.generated.resources.tags_must_be_a_list_of_lists_of_strings +import mantra.composeapp.generated.resources.that_is_not_json +import mantra.composeapp.generated.resources.the_content_is_not_a_profile +import mantra.composeapp.generated.resources.the_group_signs_an_event_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_event +import mantra.composeapp.generated.resources.this_schema_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.unsigned_event +import mantra.composeapp.generated.resources.what_the_group_would_sign +import org.jetbrains.compose.resources.stringResource + +/** + * An unsigned event, pasted, for the group to sign. + * + * The typed editors each build one kind of statement; this takes any of the + * kinds the group's screen can show, as JSON, the way another tool prints one. + * Above the paste is what the group would be signing, read back the way the + * signing screen will describe it, so a member sees "Curated schema · bitcoin.mov" + * rather than a wall of brackets before deciding -- and sees why the group will + * not be asked when the paste is something it could never show. + * + * The byline is the group's, as on the post composer and for the same reason: + * whatever is pasted goes out in the group's name, and a paste that names some + * other `pubkey` goes out in the group's name anyway. The explanatory line says + * so, because a paste with a `pubkey` in it is the one case where a member could + * reasonably expect otherwise. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ProposeGroupEventScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState = ProposeGroupEventUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel( + factory = ProposeGroupEventViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProposeGroupEventUIState = initialProposeGroupEventUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_event) + + ScreenStateTransition(proposeGroupEventViewModel.proposeGroupEventUIState) { uiState -> + when (val proposeGroupEventUIState = uiState) { + is ProposeGroupEventUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = proposeGroupEventUIState.message, onRetry = null) + } + + is ProposeGroupEventUIState.Loaded -> { + val eventFieldState = rememberTextFieldState() + val canSign = proposeGroupEventUIState.canSign + + // Read on every change. A paste is a few kilobytes at most and the + // reading is one JSON parse, so this is cheaper than the debounce + // that would hide the answer for a beat after the paste. + val json = eventFieldState.text.toString() + val reading = remember(json) { + json.takeIf { it.isNotBlank() }?.let(GroupEventProposal::read) + } + + // 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() + + // imePadding: this screen is a text field the size of the screen, + // and without it the software keyboard covers what is being pasted. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.propose_event)) } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + proposeGroupEventViewModel.proposeEvent( + localChatRoom = proposeGroupEventUIState.localChatRoom, + json = eventFieldState.text.toString(), + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. Nothing has been + // signed yet -- the event lands when + // a quorum signs -- so landing on a + // screen that does not show it would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // The reason is already under the paste; + // pressing the button anyway is answered + // rather than ignored, and the paste + // stays. Same on a failure to propose. + onRefused = {}, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose event") + Text(stringResource(Res.string.propose_event)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Whose name this goes out in, said before anything is + // pasted. It is the group's, whatever the paste says. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileAvatar( + size = MaterialTheme.spacing.space500, + publicKey = chatRoomId, + picture = proposeGroupEventUIState.groupNostrProfile?.picture(), + name = proposeGroupEventUIState.groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Text( + text = proposeGroupEventUIState.groupNostrProfile?.name() + ?: proposeGroupEventUIState.localChatRoom.chatRoom.subject + ?: chatRoomId.take(16), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.paste_an_unsigned_event_for_the_group_to_sign + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource( + Res.string.the_group_signs_an_event_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_an_event + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + when (reading) { + null -> Unit + is GroupEventProposal.Ready -> ProposalPreview(reading) + is GroupEventProposal.Refused -> RefusalNotice(reading) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = eventFieldState, + // The paste grows into the screen rather than scrolling + // inside three lines, and is set in a monospace face: + // it is JSON, and a bracket that does not line up is + // the kind of thing a member is looking for in it. + lineLimits = TextFieldLineLimits.MultiLine(), + textStyle = TextStyle(fontFamily = FontFamily.Monospace), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + label = { Text(stringResource(Res.string.unsigned_event)) }, + placeholder = { + Text( + text = stringResource(Res.string.eg_kind_1_tags_content), + fontFamily = FontFamily.Monospace + ) + }, + isError = reading is GroupEventProposal.Refused + ) + } + } + } + + ProposeGroupEventUIState.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.propose_event), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialProposeGroupEventUIState == ProposeGroupEventUIState.Loading) { + proposeGroupEventViewModel.initiateProposeGroupEvent() + } + } +} + +/** + * What the group would sign, said the way the signing screen will say it. + * + * `ProposedEvent.summarize` rather than a description of this screen's own, so + * that what a member reads here before proposing is word for word what every + * other member reads before signing. Two descriptions of one event would drift. + */ +@Composable +private fun ProposalPreview(ready: GroupEventProposal.Ready) { + val summary = ProposedEvent.summarize(ready.preview()) + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.what_the_group_would_sign), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(Res.string.kind_n, ready.kind.toString()) + " · " + summary.label, + style = MaterialTheme.typography.titleSmall + ) + + // Capped, because a kind:1's detail is the whole post and the paste + // under this card is the thing the screen is for. + Text( + text = summary.detail, + style = MaterialTheme.typography.bodyMedium, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** + * Why the group will not be asked, under the paste it is about. + * + * Named rather than silent: a button that does nothing for a paste it refuses is + * indistinguishable from a missed tap. A schema's reasons are the same sentences + * the schema editor uses, since they are the same checks. + */ +@Composable +private fun RefusalNotice(refused: GroupEventProposal.Refused) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = when (refused) { + GroupEventProposal.Refused.NotJson -> stringResource(Res.string.that_is_not_json) + GroupEventProposal.Refused.NotAnObject -> stringResource(Res.string.an_event_is_a_json_object) + GroupEventProposal.Refused.NoKind -> stringResource(Res.string.an_event_needs_a_kind) + is GroupEventProposal.Refused.KindNotAccepted -> stringResource( + Res.string.kind_n_has_nowhere_to_go_on_this_screen, + refused.kind.toString(), + GroupEventProposal.ACCEPTED_KINDS.sorted().joinToString() + ) + GroupEventProposal.Refused.TagsMalformed -> + stringResource(Res.string.tags_must_be_a_list_of_lists_of_strings) + GroupEventProposal.Refused.ContentNotText -> stringResource(Res.string.content_must_be_text) + GroupEventProposal.Refused.ProfileUnreadable -> + stringResource(Res.string.the_content_is_not_a_profile) + GroupEventProposal.Refused.PostBlank -> stringResource(Res.string.a_post_needs_something_to_say) + is GroupEventProposal.Refused.SchemaUnusable -> + stringResource(Res.string.this_schema_cannot_be_signed_as_it_is) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + if (refused is GroupEventProposal.Refused.SchemaUnusable) { + refused.problems.forEach { problem -> + Text( + text = problem.message(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } +} + +@ConformancePreviews +@Composable +private fun ProposeGroupEventScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + ProposeGroupEventScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialProposeGroupEventUIState = ProposeGroupEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the screen renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} 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 773b6dad..b27a0ac2 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.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen @@ -129,6 +130,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.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -1057,6 +1059,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProposeGroupEventScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the paste so back returns to the group rather + // than to an event whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt new file mode 100644 index 00000000..71f12f23 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/ProposeGroupEventRoute.kt @@ -0,0 +1,11 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** The untyped way into a group's signed statements: an unsigned event, pasted. */ +@Serializable +data class ProposeGroupEventRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt new file mode 100644 index 00000000..87f8f029 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposeGroupEventViewModel.kt @@ -0,0 +1,158 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +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 press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupEventProposal +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to sign an event a member pasted. + * + * Nothing is built here; the paste is the event. What this decides is whether + * the group should be asked at all -- `GroupEventProposal.read` refuses a paste + * the group's screen could never show, so a quorum is not spent on an event that + * would be signed, filed and seen by nobody -- and it hands the kind, tags and + * content to the same `proposeSigning` every typed editor uses. The author and + * the time are the session's to fill in, which is how a pasted `pubkey` or + * `created_at` comes to be ignored: they are never passed along. + * + * Where the event lands is decided by nothing here. `FrostSigningManager.complete` + * files every signed event as a `GroupSignedEvent`, and the group's screen reads + * its profile, posts, relay lists and schemas off those by kind. A pasted kind 0 + * becomes the profile the same way a kind 0 from the profile form does, because + * by the time either is signed there is no telling them apart. + */ +class ProposeGroupEventViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var proposeGroupEventUIState: ProposeGroupEventUIState by mutableStateOf( + initialProposeGroupEventUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateProposeGroupEvent() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + proposeGroupEventUIState = if (localChatRoom == null) { + ProposeGroupEventUIState.Error("Couldn't find the chat room") + } else { + ProposeGroupEventUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // statements to make -- the same condition the group detail + // screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over the pasted event, or says why not. + * + * [onRefused] carries the reading's own reason, which the screen has been + * showing under the paste all along; it is repeated here so pressing the + * button on a refused paste is answered rather than ignored. Both refusals + * and a failed proposal stay on the paste: the JSON is the thing worth + * keeping. + */ + fun proposeEvent( + localChatRoom: LocalChatRoom, + json: String, + onSuccess: (sessionId: String) -> Unit, + onRefused: (GroupEventProposal.Refused) -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. A pasted kind:1 accumulates like any + // other post, so a second session over the same paste is a second post + // on the record forever. + if (isActionPending.value) return + + val ready = when (val reading = GroupEventProposal.read(json)) { + is GroupEventProposal.Ready -> reading + is GroupEventProposal.Refused -> { + onRefused.invoke(reading) + return + } + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = ready.kind, + tags = ready.tags, + content = ready.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a pasted kind ${ready.kind} event for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "ProposeGroupEventViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialProposeGroupEventUIState: ProposeGroupEventUIState = + ProposeGroupEventUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + ProposeGroupEventViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialProposeGroupEventUIState = initialProposeGroupEventUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt new file mode 100644 index 00000000..81a2b502 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ProposeGroupEventUIState.kt @@ -0,0 +1,32 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface ProposeGroupEventUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * Who the group says it is, for the byline over the paste. + * + * Whatever is pasted goes out signed by the group and read as the group, + * so the screen shows whose name it will carry -- which is not the + * member pasting it, and not whatever `pubkey` the paste may name. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the paste writable and the button inert, for the same + * reason the typed editors do: reading and checking cost nothing, and + * only a share-holder can open the session that would sign it. + */ + val canSign: Boolean = false, + ): ProposeGroupEventUIState + + data class Error( + val message: String + ): ProposeGroupEventUIState + + data object Loading: ProposeGroupEventUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt new file mode 100644 index 00000000..a9beafd9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupEventProposalTest.kt @@ -0,0 +1,338 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSchemaProblem +import press.mantra.compose.nostr.curated.CuratedVisibility +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * What a pasted event may become, and where it goes once the group has signed it. + * + * Two halves. The first is the reading: which pastes are refused and why, and + * that what is refused is exactly what a reader would refuse -- the point of + * checking before proposing is that a quorum is never asked to sign something + * that will be filed and shown nowhere. + * + * The second is the promise the feature makes: a pasted kind 0 becomes the + * profile, a kind 1 a post, a kind 31889 a schema, a relay list a relay list. That + * is not this module's doing -- `FrostSigningManager.complete` files every signed + * event and the group's screen reads each section off those by kind -- so the + * test is the whole chain: the paste read here, signed by a real quorum through + * the same shape `FrostSigningManager.advance` runs, and read back by the reader + * that section uses. + */ +class GroupEventProposalTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + /** + * The bitcoin.mov schema as `npm run schema:dry` would print it: a whole + * event, id, author, signature and time included, abbreviated to the tags + * a reading depends on. + */ + private val pastedSchema = """ + { + "id": "b2f8c7a7b4d5e6f70123456789abcdef0123456789abcdef0123456789abcdef", + "pubkey": "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25", + "created_at": 1735689600, + "kind": 31889, + "tags": [ + ["d", "bitcoin.mov"], + ["title", "bitcoin.mov suggestion"], + ["name", "bitcoin.mov"], + ["description", "Fields for a bitcoin.mov entry."], + ["k", "31888"], + ["visibility", "public"], + ["domain", "bitcoin.mov"], + ["relay", "wss://ephemeral.mantra.press"], + ["field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"], + ["field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"], + ["field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"repeat\":true}"] + ], + "content": "Fields for a bitcoin.mov entry.", + "sig": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00" + } + """.trimIndent() + + @Test + fun `a pasted event is read as its kind, tags and content, and nothing else`() { + val ready = assertIs(GroupEventProposal.read(pastedSchema)) + + assertEquals(31889, ready.kind) + assertEquals("Fields for a bitcoin.mov entry.", ready.content) + assertEquals(11, ready.tags.size) + assertEquals(listOf("d", "bitcoin.mov"), ready.tags.first().toList()) + // The config blob comes through verbatim, escapes and all. + assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", ready.tags[8][6]) + + // The id, author, signature and time are not the paste's to decide, and + // there is nowhere on a reading for them to go: the session hashes the id + // over the room's key and the time it opens at. + val preview = ready.preview() + assertEquals("", preview.pubKey) + assertEquals(0L, preview.createdAt) + } + + @Test + fun `a bare template with no tags or content still reads`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 0, "content": "{}"}""") + ) + assertEquals(0, ready.kind) + assertEquals(0, ready.tags.size) + + // A numeric string for the kind is what a hand types; a relay writes a number. + assertIs( + GroupEventProposal.read("""{"kind": "1", "content": "Hello"}""") + ) + } + + @Test + fun `what is not an event is refused and named`() { + assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("not json at all")) + assertEquals(GroupEventProposal.Refused.NotJson, GroupEventProposal.read("{\"kind\": 1,")) + assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("[1, 2, 3]")) + assertEquals(GroupEventProposal.Refused.NotAnObject, GroupEventProposal.read("\"a string\"")) + assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"content": "Hello"}""")) + assertEquals(GroupEventProposal.Refused.NoKind, GroupEventProposal.read("""{"kind": "one"}""")) + assertEquals( + GroupEventProposal.Refused.TagsMalformed, + GroupEventProposal.read("""{"kind": 1, "tags": "e", "content": "Hello"}""") + ) + assertEquals( + GroupEventProposal.Refused.TagsMalformed, + GroupEventProposal.read("""{"kind": 1, "tags": [["e", 1]], "content": "Hello"}""") + ) + assertEquals( + GroupEventProposal.Refused.ContentNotText, + GroupEventProposal.read("""{"kind": 1, "content": {"note": "Hello"}}""") + ) + } + + @Test + fun `a kind the group's screen has no place for is refused, whatever else it says`() { + // A long-form article is a perfectly good nostr event and the group could + // sign it. It would then be filed and shown nowhere, with nothing to tell + // the member their quorum was spent on it. + assertEquals( + GroupEventProposal.Refused.KindNotAccepted(30023), + GroupEventProposal.read("""{"kind": 30023, "tags": [["d", "x"]], "content": "An article"}""") + ) + + // The kinds with a section: the profile, the posts, the four relay lists + // and the schemas. A subgroup certificate is deliberately not among them. + assertEquals( + setOf(0, 1, 10002, 10050, 10007, 10006, 31889), + GroupEventProposal.ACCEPTED_KINDS + ) + } + + @Test + fun `a kind zero whose content is not a profile is refused, and an empty one is not`() { + assertEquals( + GroupEventProposal.Refused.ProfileUnreadable, + GroupEventProposal.read("""{"kind": 0, "content": "not a profile"}""") + ) + + // Wiping a profile is a thing groups are entitled to do -- the same line + // `GroupNostrProfile.of` draws. + assertIs(GroupEventProposal.read("""{"kind": 0, "content": ""}""")) + assertIs( + GroupEventProposal.read("""{"kind": 0, "content": "{\"name\":\"Translation collective\"}"}""") + ) + } + + @Test + fun `a blank post is refused, the way the composer refuses one`() { + listOf("", " ", "\\n\\n").forEach { blank -> + assertEquals( + GroupEventProposal.Refused.PostBlank, + GroupEventProposal.read("""{"kind": 1, "content": "$blank"}"""), + "a post saying \"$blank\" was accepted" + ) + } + } + + @Test + fun `a schema a reader would refuse is refused here, with the reader's reasons`() { + val withoutVisibility = pastedSchema.replace("""["visibility", "public"],""", "") + + assertEquals( + GroupEventProposal.Refused.SchemaUnusable(listOf(CuratedSchemaProblem.VisibilityMissing)), + GroupEventProposal.read(withoutVisibility) + ) + } + + @Test + fun `a relay list reads whether or not it names a relay`() { + // An empty one is a list the group is withdrawing, which the relays card + // shows as "no relays" rather than as nothing. + assertIs(GroupEventProposal.read("""{"kind": 10002}""")) + assertIs( + GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") + ) + } + + @Test + fun `a pasted kind zero, once signed, is the group's profile`() { + val ready = assertIs( + GroupEventProposal.read( + """{"kind": 0, "pubkey": "not the group", "content": "{\"name\":\"Translation collective\",\"about\":\"We translate hard books.\"}"}""" + ) + ) + + val profile = assertNotNull(GroupNostrProfile.of(signed(ready), chatRoomId)) + + assertEquals("Translation collective", profile.name()) + assertEquals("We translate hard books.", profile.about()) + assertEquals(chatRoomId, profile.publicKey) + } + + @Test + fun `a pasted kind one, once signed, is one of the group's posts`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 1, "tags": [], "content": "We have finished the first chapter."}""") + ) + + val posts = GroupPost.newestFirstAmong(listOf(signed(ready)), chatRoomId) + + assertEquals(listOf("We have finished the first chapter."), posts.map { it.content }) + assertTrue(posts.single().isNewThread()) + } + + @Test + fun `a pasted schema, once signed, is a list the group curates`() { + val ready = assertIs(GroupEventProposal.read(pastedSchema)) + + val curated = assertNotNull(GroupCuratedSchema.of(signed(ready), chatRoomId)) + + assertEquals("bitcoin.mov", curated.identifier) + assertEquals("bitcoin.mov", curated.schema.displayName()) + assertEquals(CuratedVisibility.Public, curated.schema.visibility) + assertEquals(listOf("identifier", "title", "watchUrl"), curated.schema.fields.map { it.name }) + // The coordinate is the group's, not the pasted pubkey's: the group is + // the curator of what it signed. + assertEquals("${CuratedSchemaEvent.KIND}:$chatRoomId:bitcoin.mov", curated.coordinate()) + } + + @Test + fun `a pasted relay list, once signed, is one of the group's relay lists`() { + val ready = assertIs( + GroupEventProposal.read("""{"kind": 10002, "tags": [["r", "wss://relay.example.com", "write"]]}""") + ) + + val list = GroupRelayList.newestAmong(listOf(signed(ready)), chatRoomId, GroupRelaySet.General) + + assertTrue(list.isAgreed) + assertEquals(listOf("relay.example.com"), list.relays.map { it.displayUrl() }) + assertEquals(listOf(false), list.relays.map { it.read }) + assertEquals(listOf(true), list.relays.map { it.write }) + } + + /** + * A reading as `FrostSigningManager.complete` would file it: authored by the + * room's key at its path, the id hashed over that, a real quorum's signature + * on it, and the time the session opened at rather than anything pasted. + */ + private fun signed(ready: GroupEventProposal.Ready, createdAt: Long = 1_700_000_000): GroupSignedEvent { + val groupPubKey = SharedKeyDerivation + .derive(groupMaterial.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = ready.kind, + tags = ready.tags, + content = ready.content + ) + + return GroupSignedEvent.fromEvent( + event = Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = ready.kind, + tags = ready.tags, + content = ready.content, + sig = groupSignature(groupMaterial, id) + ), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt new file mode 100644 index 00000000..2a699980 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -0,0 +1,105 @@ +package press.mantra.compose.text + +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * How the events a group signs about itself are said to the member signing them. + * + * These are read on the signing screen, in the room's list of proposals and now + * on the paste screen before proposing, and the same words in all three is the + * point of there being one mapping. What is pinned is that each kind is said as + * the thing it is and by the part of it a signer is actually deciding about -- + * a profile by its name, a post by its words, a relay list by its hosts, a + * schema by its list -- and never as "kind 0" over a wall of JSON. + */ +class ProposedEventTest { + + @Test + fun `a profile is said by its name and what it says about itself`() { + val summary = ProposedEvent.summarize( + event(MetadataEvent.KIND, content = """{"name":"Translation collective","about":"We translate hard books."}""") + ) + + assertEquals("Nostr profile", summary.label) + assertEquals("Translation collective · We translate hard books.", summary.detail) + + // Empty and unreadable are different things, and a signer should know which. + assertEquals("An empty profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "{}")).detail) + assertEquals("Unreadable profile", ProposedEvent.summarize(event(MetadataEvent.KIND, content = "not json")).detail) + } + + @Test + fun `a post is said by its words`() { + val summary = ProposedEvent.summarize(event(TextNoteEvent.KIND, content = "We have finished the first chapter.")) + + assertEquals("Post", summary.label) + assertEquals("We have finished the first chapter.", summary.detail) + } + + @Test + fun `a relay list is said by which list it is and its hosts`() { + val summary = ProposedEvent.summarize( + event( + AdvertisedRelayListEvent.KIND, + tags = arrayOf(arrayOf("r", "wss://relay.one.example"), arrayOf("r", "wss://relay.two.example", "read")) + ) + ) + + assertEquals("General relay list", summary.label) + assertEquals("relay.one.example, relay.two.example", summary.detail) + + // An empty list is a withdrawal, which is worth saying in as many words. + assertEquals("No relays", ProposedEvent.summarize(event(AdvertisedRelayListEvent.KIND)).detail) + } + + @Test + fun `a schema is said by its list, who may suggest, and how much an entry asks for`() { + val summary = ProposedEvent.summarize( + event( + CuratedSchemaEvent.KIND, + tags = arrayOf( + arrayOf("d", "films"), + arrayOf("title", "Film suggestion"), + arrayOf("name", "Films worth translating"), + arrayOf("description", "Films the group would subtitle."), + arrayOf("visibility", "closed"), + arrayOf("field", "title", "text", "required", "", "Title", "{}"), + arrayOf("field", "year", "year", "optional", "", "Year", "{}"), + ), + content = "Films the group would subtitle." + ) + ) + + assertEquals("Curated schema", summary.label) + assertEquals("Films worth translating · closed · 2 fields", summary.detail) + } + + @Test + fun `a kind with no words of its own keeps its number`() { + // Refusing to describe an event is better than describing it wrongly. + val summary = ProposedEvent.summarize(event(30023, content = "An article")) + + assertEquals("Event of kind 30023", summary.label) + assertEquals("An article", summary.detail) + } + + private fun event( + kind: Int, + tags: Array> = emptyArray(), + content: String = "", + ) = Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1_700_000_000, + kind = kind, + tags = tags, + content = content, + sig = "c".repeat(128), + ) +} 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 ce40ccec..dead059f 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 @@ -252,6 +252,29 @@ class GroupNostrProfileSectionJvmTest { onNodeWithContentDescription("Edit schema").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()) + ) { + // Last in the identity block: after the schemas and before the divider, + // because it is about the whole block rather than any one section of it. + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val propose = onNodeWithText("Propose event").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(schemas < propose, "the paste entry climbed above the schemas") + assertTrue(propose < subgroups, "the paste entry fell out of the identity block") + } + + @Test + fun `a member holding no share of the key is offered no paste`() = render( + state(groupNostrProfile = profile, canEditNostrProfile = false) + ) { + // The same gate as every button in the block: proposing is a signature by + // the group, and `proposeSigning` throws for a device holding no share. + onNodeWithText("Propose event").assertDoesNotExist() + } + @Test fun `a group that has said nothing says so, and offers to say something`() = render( state(groupNostrProfile = null) @@ -289,6 +312,7 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Add post").assertDoesNotExist() onNodeWithText("Curated schemas").assertDoesNotExist() onNodeWithText("Add schema").assertDoesNotExist() + onNodeWithText("Propose event").assertDoesNotExist() // 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/composable/ProposeGroupEventScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt new file mode 100644 index 00000000..6b471f77 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ProposeGroupEventScreenJvmTest.kt @@ -0,0 +1,116 @@ +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.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.ProposeGroupEventUIState +import kotlin.test.Test + +/** + * What the paste screen says back as an event is typed into it. + * + * The screen's one job beyond a text field is to tell a member, before a quorum + * is asked, what the group would be signing or why it will not be asked. Both + * are read live off the paste, so both are tested by typing: a schema reads back + * as the signing screen would describe it, junk is named as junk, and a kind + * with no section to land in is refused with the kinds that have one. + */ +@OptIn(ExperimentalTestApi::class) +class ProposeGroupEventScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + + private val schema = """{"kind":31889,"created_at":1735689600,"tags":[["d","bitcoin.mov"],""" + + """["title","bitcoin.mov suggestion"],["name","bitcoin.mov"],""" + + """["description","Fields for a bitcoin.mov entry."],["visibility","public"],""" + + """["field","title","text","required","","Title","{}"]],"content":"Fields for a bitcoin.mov entry."}""" + + @Test + fun `a pasted schema is read back as the signing screen would say it`() = render(state()) { + onNodeWithText("Unsigned event").performTextInput(schema) + + onNodeWithText("What the group would sign").assertIsDisplayed() + onNodeWithText("Kind 31889 · Curated schema").assertIsDisplayed() + onNodeWithText("bitcoin.mov · public · 1 field").assertIsDisplayed() + } + + @Test + fun `junk is named as junk`() = + render(state()) { + onNodeWithText("Unsigned event").performTextInput("this is not an event") + onNodeWithText("That isn't JSON.").assertIsDisplayed() + onNodeWithText("What the group would sign").assertDoesNotExist() + } + + @Test + fun `a kind the screen cannot file is refused with the kinds it can`() = render(state()) { + onNodeWithText("Unsigned event").performTextInput("""{"kind": 30023, "content": "An article"}""") + + onNodeWithText("Kind 30023 has nowhere to go on the group's screen.", substring = true) + .assertIsDisplayed() + onNodeWithText("0, 1, 10002, 10006, 10007, 10050, 31889", substring = true).assertIsDisplayed() + } + + @Test + fun `a member holding no share of the key is told so and the button is inert`() = render( + state(canSign = false) + ) { + onNodeWithText("This group has no shared key, so it cannot sign an event.", substring = true) + .assertIsDisplayed() + onNodeWithContentDescription("Propose event").assertIsNotEnabled() + } + + private fun state(canSign: Boolean = true) = ProposeGroupEventUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "a".repeat(64), + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + canSign = canSign, + ) + + private fun render( + uiState: ProposeGroupEventUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 1200) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + ProposeGroupEventScreen( + activeUserPublicKey = "a".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + initialProposeGroupEventUIState = uiState, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = + FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } + } + } + + assertions() + } +}