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