From 8d32a69bf32bcd1322aae19d5548cba5edc2fb0d Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:23:17 +0200 Subject: [PATCH] feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign The queue's sheet used to end in the suggestion's JSON and a button to copy it. Now it ends in the one thing a member can do about a suggestion: take it into the list. That opens a form seeded from the suggestion -- an input per field the schema asks for, enums as chips, one input per value where a field repeats -- and a button that proposes a kind 31890 for the room's quorum to sign, pointing back at the suggestion it came from. The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the same field list as the reader, so what the form proposes is what the queue reads back. The one deviation is that nothing is clipped on the way through; a value the schema refuses is named under its input instead, by the queue's own verifier, before a quorum is spent on it. The d is kept whatever field writes it, so accepting twice revises one entry rather than making two, and derived fields are kept as the suggester's client filled them in, since this app derives nothing. After the quorum signs, a 31890 has somewhere to land: the signing screen says which entry and who suggested it, the transcript gets a line naming the list, and the broadcast screen seeds the schema's relays, which the NIP makes a MUST and which the entry only names by coordinate. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@fcc19f950063ff72e2864f74066d56eb23252b1d --- .../composeResources/values/strings.xml | 30 +- .../compose/database/model/ChatMessage.kt | 55 ++ .../mantra/compose/nostr/CuratedSuggestion.kt | 2 +- .../compose/nostr/GroupCuratedSchema.kt | 48 + .../nostr/curated/CuratedEntryEvent.kt | 168 +++- .../mantra/compose/text/ProposedEvent.kt | 17 + .../AcceptCuratedSuggestionScreen.kt | 843 ++++++++++++++++++ .../BroadcastGroupSignedEventScreen.kt | 1 + .../composable/CuratedSuggestionListScreen.kt | 111 ++- .../ui/composable/navigation/MantraNavHost.kt | 31 + .../routes/AcceptCuratedSuggestionRoute.kt | 22 + .../model/AcceptCuratedSuggestionViewModel.kt | 324 +++++++ .../BroadcastGroupSignedEventViewModel.kt | 44 +- .../state/AcceptCuratedSuggestionUIState.kt | 34 + .../state/BroadcastGroupSignedEventUIState.kt | 7 + .../nostr/curated/CuratedEntryEventTest.kt | 164 ++++ .../mantra/compose/text/ProposedEventTest.kt | 34 + .../AcceptCuratedSuggestionScreenJvmTest.kt | 216 +++++ .../CuratedSuggestionListScreenJvmTest.kt | 43 +- ...AcceptCuratedSuggestionViewModelJvmTest.kt | 412 +++++++++ ...oadcastGroupSignedEventViewModelJvmTest.kt | 50 ++ 21 files changed, 2571 insertions(+), 85 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c85da50e..4644ca10 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -583,8 +583,34 @@ Curated Suggested by %1$s on %2$s Taken up by the group into the list. - Copy the suggestion - Copied the suggestion + Accept this suggestion + Revise the list's entry + + Take this suggestion into %1$s, edited as the group wants it. The entry keeps the suggestion's identifier and points back at it, so the suggester is credited. + The group signs an entry, so it takes a quorum. Nothing changes until one has. + This group has no shared key, so it cannot sign an entry. Run a shared key ceremony first. + The group already has an entry for this. Proposing again revises it in place rather than adding a second one. + Propose entry + Couldn't ask the group to sign this entry. Please try again. + This entry can't be signed as it is: + The entry can't be signed as it is. + Kept from the suggestion + Filled in by the suggester's app and kept as they came, the identifier among them, so that an entry accepted twice lands on the same coordinate. + Add another + This field is required. + Only one value for this field. + One word, without spaces. + That is not a web address. + That must be an https address. + Pick one of the options. + A whole number. + More than zero. + At least %1$s. + At most %1$s. + At most %1$s characters. + Must match %1$s. + At least one of these is needed: %1$s. + %1$s: %2$s Broadcast Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 539ee32a..8221ba82 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -447,11 +448,22 @@ data class ChatMessage( */ const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned" + /** + * The group signed an entry into a list it curates. + * + * A system line like the schema's, and it names the entry and the list + * rather than the fields: what a reader of the room needs to know is + * that the list now stands behind a title, and the entry itself is on + * the queue screen where its fields can be read. + */ + const val TYPE_GROUP_ENTRY_CURATED = "groupEntryCurated" + val GROUP_IDENTITY_TYPES = setOf( TYPE_GROUP_PROFILE_SIGNED, TYPE_GROUP_RELAYS_SIGNED, TYPE_GROUP_POST_SIGNED, TYPE_GROUP_SCHEMA_SIGNED, + TYPE_GROUP_ENTRY_CURATED, ) /** @@ -1579,6 +1591,49 @@ data class ChatMessage( ) } + // The group taking an entry into a list it curates: the kind + // 31890 that only its own key can publish, and the thing the + // queue screen marks a suggestion "curated" by once it is read + // back off a relay. + // + // Verified, for the reason the schema arm is: a rumor of this + // kind from a member is not the group curating anything. Named + // after the list where this device holds its schema -- looked + // up by the coordinate the entry replies to -- and after the + // entry alone where it does not, since the entry is what was + // signed either way. + CuratedEntryEvent.CANONICAL_KIND -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return null + + val entry = CuratedEntryEvent.headline(event) + val title = entry.title.ifBlank { entry.identifier }.ifBlank { "an entry" } + + val list = runCatching { + GroupCuratedSchema.newestPerListAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(groupId, CuratedSchemaEvent.KIND), + chatRoomId = groupId, + ).firstOrNull { it.isRepliedToBy(event.tags) } + }.getOrNull() + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_ENTRY_CURATED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = if (list != null) { + "The group accepted $title into ${list.schema.displayName()}" + } else { + "The group accepted $title into a list it curates" + } + ) + } + else -> unsupported() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt index 67fcdb00..a7c1ff88 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt @@ -34,7 +34,7 @@ import kotlin.time.Instant data class CuratedSuggestion( /** The entry, keyed by the schema's field names. */ val entry: CuratedEntry, - /** The event as it was stored, for the id, the tags, and copying it somewhere. */ + /** The event as it was stored, for the id and the tags: what a canonical entry points back at. */ val event: NostrEvent, /** The suggester's profile where one has arrived, else null. */ val suggester: Profile?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt index 1d6727d8..1622e453 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt @@ -2,8 +2,11 @@ package press.mantra.compose.nostr import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedEntryProblem import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlin.time.Instant @@ -57,6 +60,14 @@ data class GroupCuratedSchema( /** What a suggestion's `a` root names to reply to this list. */ fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) + /** + * Whether an entry carrying [tags] replies to this list: its `a` tags + * include this coordinate. How a reader holding several of the group's + * schemas tells which one an entry belongs to. + */ + fun isRepliedToBy(tags: Array>): Boolean = + tags.any { it.size > 1 && it[0] == COORDINATE_TAG && it[1] == coordinate() } + /** * What to ask a relay for to read this list's queue: the NIP's two queries, * as one request. @@ -93,6 +104,43 @@ data class GroupCuratedSchema( tags = mapOf(COORDINATE_TAG to listOf(coordinate())), ) + /** + * The event to ask the group to sign to take an entry into the list: + * [values] as this schema lays them out, rooted at [coordinate], with the + * group as the curator and [source] as the suggestion it came from. + * + * The group is the curator, so its key is the `p` on the root and the only + * author [verifyCanonical] will accept. See [CuratedEntryEvent.canonicalTemplate] + * for what is and is not done to the values on the way through. + */ + fun canonicalTemplate( + values: Map>, + source: CuratedSuggestionRef?, + ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate(), + curator = publicKey, + values = values, + source = source, + ) + + /** + * Every reason this list's readers would refuse [template] as one of its + * own entries, or nothing. + * + * The same check `CuratedSuggestion.queueOf` makes on a kind 31890 off a + * relay, made before the group is asked to sign one: a signature is the + * most expensive thing this app does, and an entry the queue would then + * refuse to count is a quorum spent on nothing. + */ + fun verifyCanonical(template: EventTemplate<*>): List = + CuratedEntryEvent.verifyCanonical( + event = CuratedEntryEvent.preview(template, publicKey), + schema = schema, + coordinate = coordinate(), + curator = publicKey, + ) + companion object { /** The tag a reply carries its schema's coordinate in. */ private const val COORDINATE_TAG = "a" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt index 340239f4..5543e0aa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt @@ -3,6 +3,8 @@ package press.mantra.compose.nostr.curated import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.time.Clock /** * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and @@ -18,7 +20,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind * This is the other half of [CuratedSchemaEvent]: that is what a list *asks* * for, this is what it *gets*. Ported from the reference module's `verifyEntry` * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts - * and an entry it refuses is refused here for the same reason. + * and an entry it refuses is refused here for the same reason. The write side, + * [canonicalTemplate], is the port of its `buildCuratedCanonicalTemplate`, and + * walks the same field list as [read] so the two cannot drift. * * ### Rejected, not repaired * @@ -40,6 +44,12 @@ object CuratedEntryEvent { private const val TAG_EVENT = "e" private const val TAG_IDENTIFIER = "d" private const val TAG_TITLE = "title" + private const val TAG_PUBKEY = "p" + private const val TAG_KIND = "k" + + /** NIP-10's markers: the schema being replied to, and the suggestion being credited. */ + private const val MARKER_ROOT = "root" + private const val MARKER_MENTION = "mention" /** What every schema coordinate starts with, which is how a root is told from a source. */ private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:" @@ -87,6 +97,20 @@ object CuratedEntryEvent { ) } + /** + * The parts of an entry that need no schema to read: its identifier, its + * title and, for a canonical entry, where it came from. + * + * For a reader that has the event and not the list -- the signing screen, + * the transcript -- and wants to say which entry this is rather than check + * it. [CuratedEntry.values] is empty, since without a schema there are no + * fields to key them by. + */ + fun headline(event: Event): CuratedEntry = read(event, NO_SCHEMA) + + /** A schema with no fields, for [headline]: the two lifted tags are read regardless. */ + private val NO_SCHEMA = CuratedSchema(identifier = "", title = "", name = "", description = "", visibility = null) + /** * Every reason [event] is not a suggestion to the list at [coordinate], or * nothing. @@ -128,6 +152,128 @@ object CuratedEntryEvent { } } + /** + * The unsigned canonical entry for the curator to sign: [values] laid out as + * the schema's tags, rooted at the schema's [coordinate], and pointing back + * at [source] when it came from a suggestion. + * + * The reverse of [read], and the reason a canonical entry and the suggestion + * it came from are one shape: both walk the schema's one field list, so what + * this writes is what [canonical] reads back, field for field. Ported from + * the reference module's `buildCuratedCanonicalTemplate` tag for tag, so + * that an entry this app signs is one bitcoin.mov's list shows. + * + * [values] is keyed by field name the way [CuratedEntry.values] is, derived + * fields included. The reference client derives `d` and `t` for a suggestion + * it is composing; this app composes none, and a suggestion it accepts keeps + * what the suggester's client derived -- the `d` above all, which the NIP + * says a canonical entry SHOULD keep so that re-curating revises the entry + * in place rather than adding a second one. Nothing is clipped or repaired + * on the way through: a value the schema refuses is refused by + * [verifyCanonical] on the result, with the field named, which is what a + * form wants to show. A blank value is not a value and writes nothing, and a + * field without `repeat` writes its first value only. + * + * [relayHint] goes on the root, per NIP-10: where a reader who does not know + * the list can find the schema being replied to. The NIP says it SHOULD be + * the first of the schema's relays, and by default it is. + */ + fun canonicalTemplate( + schema: CuratedSchema, + coordinate: String, + curator: HexKey, + values: Map>, + source: CuratedSuggestionRef? = null, + relayHint: String? = schema.relays.firstOrNull(), + createdAt: Long = Clock.System.now().epochSeconds, + ): EventTemplate { + val (fieldTags, content) = fieldTags(schema, values) + + return EventTemplate( + createdAt = createdAt, + kind = CANONICAL_KIND, + tags = (fieldTags + replyTags(coordinate, curator, relayHint) + sourceTags(source)).toTypedArray(), + content = content, + ) + } + + /** + * [template] as an event the verifiers can read: the kind, the tags, the + * content and [pubKey], which is everything they look at, over a blank id + * and signature, which they do not. For checking what is about to be + * proposed before a quorum is spent on it. + */ + fun preview(template: EventTemplate<*>, pubKey: HexKey): Event = Event( + id = "", + pubKey = pubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = "", + ) + + /** + * The tags that make an entry a reply to its schema, and nothing else. + * + * The root is the schema's coordinate rather than an event id, because the + * kind is addressable: revising the schema mints a new id and keeps the + * coordinate, and an entry pinned to an id would be orphaned by the first + * edit. `p` and `k` are the reply courtesies the NIP asks for and nothing + * verifies; `a` is what is verified and what relays are queried on. + */ + fun replyTags(coordinate: String, curator: HexKey, relayHint: String?): List> = listOf( + arrayOf(TAG_ADDRESS, coordinate, relayHint ?: "", MARKER_ROOT), + arrayOf(TAG_PUBKEY, curator), + arrayOf(TAG_KIND, CuratedSchemaEvent.KIND.toString()), + ) + + /** + * The values as the schema's tags: one per value at the field's tag, with + * its marker as the third element when it has one, or the body for the + * field that writes there. + */ + private fun fieldTags(schema: CuratedSchema, values: Map>): Pair>, String> { + val tags = mutableListOf>() + var content = "" + + for (field in schema.fields) { + val tag = field.tag() + val marker = field.config.marker + + for (value in values[field.name].orEmpty()) { + val trimmed = value.trim() + if (trimmed.isEmpty()) continue + + if (tag == CuratedFieldConfig.CONTENT_TAG) { + content = trimmed + } else { + tags.add(if (marker != null) arrayOf(tag, trimmed, marker) else arrayOf(tag, trimmed)) + } + + if (field.config.repeat != true) break + } + } + + return tags to content + } + + /** + * Both halves of the pointer back: the coordinate, which follows the + * suggester's later edits, and the id, which pins what the curator actually + * read. They answer different questions, which is why the NIP has both. The + * `p` credits the suggester. + */ + private fun sourceTags(source: CuratedSuggestionRef?): List> { + if (source == null) return emptyList() + + return buildList { + add(arrayOf(TAG_ADDRESS, source.coordinate, "", MARKER_MENTION)) + source.id?.let { add(arrayOf(TAG_EVENT, it, "", MARKER_MENTION)) } + add(arrayOf(TAG_PUBKEY, source.pubKey)) + } + } + /** * One value against its field's type and config, or the first thing wrong * with it. @@ -359,7 +505,25 @@ data class CuratedSuggestionRef( val coordinate: String, /** The suggester, credited. */ val pubKey: HexKey, -) +) { + companion object { + /** + * The pointer a canonical entry made from [entry] carries: its id, its + * coordinate and its author. Only a suggestion can be pointed at -- a + * canonical entry is the list's own word, and the list did not suggest + * anything to itself. + */ + fun of(entry: CuratedEntry): CuratedSuggestionRef { + require(!entry.isCanonical) { "A canonical entry is not the source of another" } + + return CuratedSuggestionRef( + id = entry.id, + coordinate = entry.coordinate(), + pubKey = entry.pubKey, + ) + } + } +} /** * One reason an entry does not satisfy its schema: which field, and what about 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 fe9c10f3..926ab6c5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -3,8 +3,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.extensions.shortened import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -208,6 +210,21 @@ object ProposedEvent { } ) + // An entry the group would take into one of its lists. A member signing + // this is agreeing that the list stands behind this title, so the + // summary is the title and who suggested it -- the credit the entry + // carries -- and not its fields, which the form showed whole before the + // proposal was made and the queue shows again once it lands. + CuratedEntryEvent.CANONICAL_KIND -> Summary( + label = "Curated entry", + detail = CuratedEntryEvent.headline(event).let { entry -> + listOfNotNull( + entry.title.ifBlank { entry.identifier }.ifBlank { null }, + entry.source?.let { "suggested by ${it.pubKey.shortened()}" } + ).joinToString(" · ").ifEmpty { "An untitled entry" } + } + ) + else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt new file mode 100644 index 00000000..9b21092f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreen.kt @@ -0,0 +1,843 @@ +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +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.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.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.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_whole_number +import mantra.composeapp.generated.resources.add_another +import mantra.composeapp.generated.resources.at_least_n +import mantra.composeapp.generated.resources.at_least_one_of_these_is_needed +import mantra.composeapp.generated.resources.at_most_n +import mantra.composeapp.generated.resources.at_most_n_characters +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_entry +import mantra.composeapp.generated.resources.field_and_problem +import mantra.composeapp.generated.resources.filled_in_by_the_suggester_s_app_and_kept +import mantra.composeapp.generated.resources.kept_from_the_suggestion +import mantra.composeapp.generated.resources.more_than_zero +import mantra.composeapp.generated.resources.must_match_the_pattern +import mantra.composeapp.generated.resources.one_word_without_spaces +import mantra.composeapp.generated.resources.only_one_value_for_this_field +import mantra.composeapp.generated.resources.pick_one_of_the_options +import mantra.composeapp.generated.resources.propose_entry +import mantra.composeapp.generated.resources.suggested_by_on +import mantra.composeapp.generated.resources.take_this_suggestion_into_the_list +import mantra.composeapp.generated.resources.that_is_not_a_web_address +import mantra.composeapp.generated.resources.that_must_be_an_https_address +import mantra.composeapp.generated.resources.the_entry_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.the_group_already_has_an_entry_for_this +import mantra.composeapp.generated.resources.the_group_signs_an_entry_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_entry_cannot_be_signed_as_it_is +import mantra.composeapp.generated.resources.this_field_is_required +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_entry +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +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.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AcceptCuratedSuggestionViewModel +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.time.Instant + +/** + * The form for taking one suggestion into a list the group curates. + * + * The queue screen shows what was suggested; this is where the group decides + * what the list will say. Every field the schema's form asks for is an input + * seeded from the suggestion, because curation is editorial -- the NIP lets + * the curator fix a year or swap a poster on the way through, and a button + * that could only say yes would leave a typo in the list forever. What the + * form does not ask for it shows: the identifier, which the entry keeps so + * that accepting it twice revises one entry rather than making two, and the + * fields the suggester's app filled in, which this app has no way to fill in + * itself. + * + * **The button proposes rather than saves.** The entry goes out as one kind + * 31890 for the room's quorum to put its key to, pointing back at the + * suggestion it came from, and the screen hands over to the signing session it + * opened. Nothing about the list has changed by the time this screen closes. + * The form is driven by a schema this screen has never seen, so a value the + * schema refuses is named under its own input rather than found out after a + * quorum has signed. + */ +@OptIn( + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalMaterial3Api::class, + ExperimentalLayoutApi::class +) +@Composable +fun AcceptCuratedSuggestionScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = + AcceptCuratedSuggestionUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateBack: () -> Unit, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val acceptCuratedSuggestionViewModel: AcceptCuratedSuggestionViewModel = viewModel( + factory = AcceptCuratedSuggestionViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + LaunchedEffect(true) { + if (initialAcceptCuratedSuggestionUIState == AcceptCuratedSuggestionUIState.Loading) { + acceptCuratedSuggestionViewModel.initiate() + } + } + + // Read out here rather than in a 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_entry) + + ScreenStateTransition(acceptCuratedSuggestionViewModel.acceptCuratedSuggestionUIState) { uiState -> + when (val state = uiState) { + is AcceptCuratedSuggestionUIState.Error -> { + // Nothing to retry: the room, the schema and the suggestion came + // from navigation arguments, and reading them again with the + // same ones fails the same way. + ErrorState(message = state.message, onRetry = null) + } + + AcceptCuratedSuggestionUIState.Loading -> { + LoadingDataIndicator() + } + + is AcceptCuratedSuggestionUIState.Loaded -> { + val canSign = state.canSign + + // The inputs come from the state rather than from whatever loaded + // it, so a screen handed a loaded state -- a preview, a layout + // test -- fills in the same as the app does. Seeding runs once. + LaunchedEffect(state) { + acceptCuratedSuggestionViewModel.seedWorkingCopy(state) + } + + // 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() + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { state.localChatRoom.RenderChatRoomTitleText() }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + }, + 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 + + acceptCuratedSuggestionViewModel.proposeEntry( + state = state, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the queue. The entry is not in + // the list yet -- it goes in when a + // quorum signs -- so landing on a + // queue still showing the row as + // waiting would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // The reasons are drawn on the form + // itself, under the inputs they are + // about; a snackbar would name a + // problem and scroll away from it. + onInvalid = {}, + // Stays on the form with the edits still + // in it: a failure to open a session is + // no reason to throw away the typing. + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose entry") + Text(stringResource(Res.string.propose_entry)) + } + } + ) + } + ) { innerPadding -> + AcceptCuratedSuggestionForm( + state = state, + viewModel = acceptCuratedSuggestionViewModel, + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) + } + } + } + } +} + +/** + * The form under the bars: what is being taken up and into what, the reasons + * it cannot be as it stands, an input per field the schema asks for, and the + * fields it keeps. + * + * A lazy list rather than a scrolling column, the way the schema editor is, + * because the number of inputs is the schema's to decide and a long form is + * still one screen. Split out so a layout test can render it without the bars. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AcceptCuratedSuggestionForm( + state: AcceptCuratedSuggestionUIState.Loaded, + viewModel: AcceptCuratedSuggestionViewModel, + modifier: Modifier = Modifier +) { + val schema = state.schema.schema + val suggestion = state.suggestion + val editable = AcceptCuratedSuggestionViewModel.editableFields(state) + val kept = AcceptCuratedSuggestionViewModel.keptFields(state) + val entryProblems = viewModel.problemsAboutTheEntry(state) + + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenMargin, + vertical = MaterialTheme.spacing.itemGap + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Text( + text = suggestion.entry.title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + item { + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = stringResource(Res.string.take_this_suggestion_into_the_list, schema.displayName()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + item { + Text( + text = stringResource(Res.string.the_group_signs_an_entry_so_it_takes_a_quorum), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + // Said up front rather than left to the queue's mark: a member who did + // not notice would propose what they took for a new entry and get a + // revision, which is the right outcome but a surprising one. + if (suggestion.isCurated) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.the_group_already_has_an_entry_for_this), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + if (!state.canSign) { + item { + Text( + text = stringResource(Res.string.this_group_has_no_shared_key_to_sign_an_entry), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + // The problems no input can carry: about the entry as a whole, or about + // a field the form keeps rather than asks for. Named, and all at once, + // for the reason the schema editor gives -- a button that does nothing + // is indistinguishable from a missed tap. + if (entryProblems.isNotEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.this_entry_cannot_be_signed_as_it_is), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + + entryProblems.forEach { problem -> + val field = schema.fields.firstOrNull { it.name == problem.field } + + Text( + text = stringResource( + Res.string.field_and_problem, + field?.labelOrName() ?: problem.field, + problem.message(field, schema) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + + editable.forEach { field -> + item(key = field.name) { + val inputs = viewModel.inputs[field.name].orEmpty() + val problem = viewModel.problemsFor(field.name).firstOrNull() + + EntryFieldEditor( + field = field, + schema = schema, + inputs = inputs, + problem = problem, + onAddValue = { viewModel.addValue(field.name) }, + onRemoveValue = { index -> viewModel.removeValue(field.name, index) } + ) + } + } + + if (kept.isNotEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.kept_from_the_suggestion), + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = stringResource(Res.string.filled_in_by_the_suggester_s_app_and_kept), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + kept.forEach { field -> + item(key = "kept:${field.name}") { + KeptField(field = field, values = suggestion.entry.values[field.name].orEmpty()) + } + } + } + } +} + +/** + * The inputs for one field, in the shape its type asks for. + * + * An enum is its options as chips, one selected, because typing a word that + * has to be one of six is a way to misspell it; everything else is a text + * field with the keyboard its type wants. A field that repeats is one input + * per value with a way to drop each and a way to add one, since the + * suggestion may have carried two watch links and the list may want a third. + * The schema's hint sits under the input until a problem takes its place. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EntryFieldEditor( + field: CuratedField, + schema: CuratedSchema, + inputs: List, + problem: CuratedEntryProblem?, + onAddValue: () -> Unit, + onRemoveValue: (index: Int) -> Unit +) { + val message = problem?.message(field, schema) + val options = field.config.options + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + if (field.type == CuratedFieldType.Enum && !options.isNullOrEmpty()) { + EnumInput(field = field, options = options, input = inputs.firstOrNull(), message = message) + } else { + inputs.forEachIndexed { index, input -> + Row(verticalAlignment = Alignment.CenterVertically) { + EntryTextField( + modifier = Modifier.weight(1f), + state = input, + field = field, + // A problem is about the field, and the field is every + // input; the first is where a reader's eye lands. + message = message.takeIf { index == 0 }, + isError = problem != null + ) + + if (field.config.repeat == true) { + IconButton(onClick = { onRemoveValue(index) }) { + Icon( + Icons.Default.Delete, + contentDescription = "Remove this value", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + if (field.config.repeat == true) { + TextButton(onClick = onAddValue) { + Icon(Icons.Default.Add, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + Text(stringResource(Res.string.add_another)) + } + } + } + } +} + +/** + * An enum as chips. The selection is written into the field's one input, so + * the draft reads an enum the way it reads everything else; a value the + * suggestion carried that is not among the options is shown as an unselected + * set with the problem under it, rather than quietly dropped. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun EnumInput( + field: CuratedField, + options: List, + input: TextFieldState?, + message: String? +) { + val selected = input?.text?.toString() + + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + options.forEach { option -> + FilterChip( + selected = option == selected, + onClick = { input?.setTextAndPlaceCursorAtEnd(option) }, + label = { Text(option) } + ) + } + } + + (message ?: field.config.hint)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * One text input of the form, in the shape the app's other forms use: the + * borderless outlined field over the bottom bar's tint, labelled as the + * schema labels it and placeholdered as the schema says. + * + * The keyboard follows the type -- digits for a year, a URL keyboard for a + * link -- and a long text gets room to be one. The supporting line is the + * problem when there is one and the schema's hint when there is not. + */ +@Composable +private fun EntryTextField( + state: TextFieldState, + field: CuratedField, + message: String?, + isError: Boolean, + modifier: Modifier = Modifier +) { + val keyboardType = when (field.type) { + CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> KeyboardType.Number + CuratedFieldType.Url, CuratedFieldType.Image -> KeyboardType.Uri + else -> KeyboardType.Text + } + + OutlinedTextField( + modifier = modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor), + state = state, + lineLimits = if (field.type == CuratedFieldType.LongText) { + TextFieldLineLimits.MultiLine(minHeightInLines = 3) + } else { + TextFieldLineLimits.SingleLine + }, + isError = isError, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + label = { + Text(text = field.labelOrName()) + }, + placeholder = field.placeholder.takeIf { it.isNotBlank() }?.let { { Text(text = it) } }, + supportingText = (message ?: field.config.hint)?.let { { Text(text = it) } } + ) +} + +/** + * A field the form keeps rather than asks for: the schema's label over the + * values as the suggestion carried them, the way the queue's sheet shows an + * entry. Monospaced where the value is a thing rather than prose. + */ +@Composable +private fun KeptField(field: CuratedField, values: List) { + val isMonospaced = when (field.type) { + CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true + else -> false + } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = values.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospaced) FontFamily.Monospace else null, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * One problem in words, for the input it is about. + * + * The bounds are read off the field's config, since "at most 500" says + * something and "too long" does not; a `require-any` rule names the other + * fields it could have been satisfied by, as the schema labels them. + */ +@Composable +internal fun CuratedEntryProblem.message(field: CuratedField?, schema: CuratedSchema): String = when (reason) { + CuratedEntryProblem.Reason.Missing, + CuratedEntryProblem.Reason.Empty -> stringResource(Res.string.this_field_is_required) + + CuratedEntryProblem.Reason.Repeated -> stringResource(Res.string.only_one_value_for_this_field) + CuratedEntryProblem.Reason.NotOneWord -> stringResource(Res.string.one_word_without_spaces) + CuratedEntryProblem.Reason.NotAUrl -> stringResource(Res.string.that_is_not_a_web_address) + CuratedEntryProblem.Reason.NotHttps -> stringResource(Res.string.that_must_be_an_https_address) + CuratedEntryProblem.Reason.NotAnOption -> stringResource(Res.string.pick_one_of_the_options) + CuratedEntryProblem.Reason.NotAWholeNumber -> stringResource(Res.string.a_whole_number) + CuratedEntryProblem.Reason.NotPositive -> stringResource(Res.string.more_than_zero) + + CuratedEntryProblem.Reason.BelowMinimum -> + stringResource(Res.string.at_least_n, field?.config?.min?.toString() ?: "") + + CuratedEntryProblem.Reason.AboveMaximum -> + stringResource(Res.string.at_most_n, field?.config?.max?.toString() ?: "") + + CuratedEntryProblem.Reason.TooLong -> + stringResource(Res.string.at_most_n_characters, field?.config?.max?.toString() ?: "") + + CuratedEntryProblem.Reason.PatternMismatch -> + stringResource(Res.string.must_match_the_pattern, field?.config?.pattern ?: "") + + CuratedEntryProblem.Reason.RequireAnyUnmet -> stringResource( + Res.string.at_least_one_of_these_is_needed, + schema.requireAny.firstOrNull { it.firstOrNull() == this.field } + .orEmpty() + .joinToString { name -> schema.fields.firstOrNull { it.name == name }?.labelOrName() ?: name } + ) + + // None of these can come out of the form: the builder writes the kind, the + // root and the source, and the group is the curator. Named all the same, + // since a check that cannot fail is the one that does. + CuratedEntryProblem.Reason.WrongKind, + CuratedEntryProblem.Reason.NotAReply, + CuratedEntryProblem.Reason.RepliesElsewhere, + CuratedEntryProblem.Reason.NotPermitted, + CuratedEntryProblem.Reason.NotTheCurator, + CuratedEntryProblem.Reason.SourceMalformed -> stringResource(Res.string.the_entry_cannot_be_signed_as_it_is) +} + +@ConformancePreviews +@Composable +private fun AcceptCuratedSuggestionScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField( + "type", + CuratedFieldType.Enum, + isRequired = true, + label = "Type", + config = CuratedFieldConfig(options = listOf("movie", "documentary", "short")) + ), + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = false, + label = "Watch URL", + config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true) + ), + CuratedField( + "description", + CuratedFieldType.LongText, + isRequired = false, + label = "Why", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + CuratedField( + "hashtags", + CuratedFieldType.Token, + isRequired = false, + label = "Hashtags", + config = CuratedFieldConfig(tag = "t", repeat = true, derived = true) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + val suggestion = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = "a".repeat(64), + pubKey = suggester, + createdAt = 1_700_000_900, + identifier = "the-rise-and-rise-of-bitcoin-2014", + title = "The Rise and Rise of Bitcoin", + values = mapOf( + "identifier" to listOf("the-rise-and-rise-of-bitcoin-2014"), + "title" to listOf("The Rise and Rise of Bitcoin"), + "year" to listOf("2014"), + "type" to listOf("documentary"), + "watchUrl" to listOf("http://bitcoindoc.com/"), + "description" to listOf("Follows the early Bitcoin community from 2011 onward."), + "hashtags" to listOf("bitcoin", "documentary"), + ) + ), + event = NostrEvent( + id = "a".repeat(64), + pubKey = suggester, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_900) + ), + suggester = null, + isCurated = false + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AcceptCuratedSuggestionScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + suggestionId = suggestion.id, + initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "u".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + suggestion = suggestion, + canSign = true + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {}, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 7314fbfa..17733468 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -185,6 +185,7 @@ fun BroadcastGroupSignedEventScreen( kind = signedEvent.kind, tags = signedEvent.tags, content = signedEvent.content, + schemas = broadcastGroupSignedEventUIState.schemas, ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt index b9378833..e126cc8a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -14,11 +14,11 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -33,13 +33,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.viewmodel.compose.viewModel @@ -47,10 +44,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.accept_this_suggestion import mantra.composeapp.generated.resources.ask_again import mantra.composeapp.generated.resources.asking_the_list_s_relays -import mantra.composeapp.generated.resources.copied_the_suggestion -import mantra.composeapp.generated.resources.copy_the_suggestion import mantra.composeapp.generated.resources.curated import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list import mantra.composeapp.generated.resources.n_curated @@ -58,10 +54,10 @@ import mantra.composeapp.generated.resources.n_suggestions import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet import mantra.composeapp.generated.resources.one_suggestion import mantra.composeapp.generated.resources.read_from_relays +import mantra.composeapp.generated.resources.revise_the_list_s_entry import mantra.composeapp.generated.resources.suggested_by_on import mantra.composeapp.generated.resources.suggestions import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list -import mantra.composeapp.generated.resources.the_signed_event import org.jetbrains.compose.resources.stringResource import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupSignedEvent @@ -79,6 +75,8 @@ import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.curated.CuratedVisibility import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.navigation.routes.AcceptCuratedSuggestionRoute +import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.EmptyState import press.mantra.compose.ui.composable.widgets.ErrorState @@ -87,7 +85,6 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet 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 @@ -109,9 +106,9 @@ import kotlin.time.Instant * same from here. A suggestion that does not satisfy the schema is not on this * screen at all; see `CuratedSuggestion`. * - * A row opens a sheet with the whole entry, field by field, and the event it - * came as -- the thing a member can hand to somebody else to check, and the - * thing a canonical entry is made from. + * A row opens a sheet with the whole entry, field by field, and the one thing + * a member can do about it from here: take it into the list, which opens the + * form that proposes the canonical entry for the group to sign. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -125,6 +122,7 @@ fun CuratedSuggestionListScreen( nostrRepository: NostrRepository, chatRepository: ChatRepository, onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, ) { val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel( factory = CuratedSuggestionListViewModel.factory( @@ -247,6 +245,18 @@ fun CuratedSuggestionListScreen( CuratedSuggestionSheet( suggestion = suggestion, schema = state.schema.schema, + onAccept = { + openSuggestionId = null + onNavigateToRoute( + AcceptCuratedSuggestionRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestion.id + ) + ) + }, onDismiss = { openSuggestionId = null } ) } @@ -427,23 +437,24 @@ private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = private const val TITLE_TAG = "title" /** - * The whole of one suggestion, and a way to take it somewhere else. + * The whole of one suggestion, and the way to take it into the list. * * Every field the entry has a value for, labelled as the schema labels it, so - * the sheet reads as the form would have -- and then the event itself, which - * is the only part that can be checked, and the part a canonical entry is - * built from. Byte for byte, the way the key state sheet shows the group's own - * event, and for the same reason: a prettier rendering is a different string - * to the one whose id was hashed. + * the sheet reads as the form would have -- and then the button, which is the + * one thing a member can do about a suggestion: open the form that proposes it + * as the list's own, for the group to sign. The event itself is not shown; it + * is what the form is built from, and the form shows it as fields rather than + * as JSON, which is how a member reads it and how the group will sign it. */ @Composable private fun CuratedSuggestionSheet( suggestion: CuratedSuggestion, schema: CuratedSchema, + onAccept: () -> Unit, onDismiss: () -> Unit ) { ModalBottomSheet(onDismiss = onDismiss) { - CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema) + CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema, onAccept = onAccept) } } @@ -455,15 +466,9 @@ private fun CuratedSuggestionSheet( @Composable internal fun CuratedSuggestionSheetContent( suggestion: CuratedSuggestion, - schema: CuratedSchema + schema: CuratedSchema, + onAccept: () -> Unit ) { - val clipboardManager = LocalClipboardManager.current - - // Read out here rather than in the click handler: both of these are - // composable, and an onClick lambda is not. - val notify = rememberNotifier(rememberCoroutineScope()) - val copied = stringResource(Res.string.copied_the_suggestion) - val entry = suggestion.entry Column(modifier = Modifier.fillMaxWidth()) { @@ -514,44 +519,27 @@ internal fun CuratedSuggestionSheetContent( } } - Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) - - Text( - text = stringResource(Res.string.the_signed_event), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) - - val json = suggestion.event.toEvent().toJson() - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small - ) { - Text( - text = json, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) - ) - } - Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) - FilledTonalButton( - onClick = { - clipboardManager.setText(AnnotatedString(json)) - notify(copied) - }, + // The same button either way, worded for what it will do: a suggestion + // the group already took up is revised in place, since the canonical + // entry keeps its identifier, and a member should not have to find that + // out from the queue afterwards. + Button( + onClick = onAccept, modifier = Modifier.fillMaxWidth() ) { - Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Icon(Icons.Default.Draw, contentDescription = Decorative) Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) - Text(text = stringResource(Res.string.copy_the_suggestion)) + Text( + text = stringResource( + if (suggestion.isCurated) { + Res.string.revise_the_list_s_entry + } else { + Res.string.accept_this_suggestion + } + ) + ) } } } @@ -683,7 +671,8 @@ private fun CuratedSuggestionListScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {} + onNavigateBack = {}, + onNavigateToRoute = {} ) } } 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 62dc90ad..5cdb1334 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 @@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen 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.AcceptCuratedSuggestionScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen import press.mantra.compose.ui.composable.CuratedSuggestionListScreen @@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute 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.AcceptCuratedSuggestionRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute @@ -1128,6 +1130,35 @@ fun MantraNavHost( chatRepository = databaseChatRepository, onNavigateBack = { navController.popBackStack() + }, + onNavigateToRoute = { acceptRoute -> + navController.navigate(route = acceptRoute) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AcceptCuratedSuggestionScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + identifier = route.identifier, + suggestionId = route.suggestionId, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the form so back returns to the queue rather + // than to an entry whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt new file mode 100644 index 00000000..2ade6774 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AcceptCuratedSuggestionRoute.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The form for taking one suggestion into a list the group curates: the entry + * as it was suggested, open for the group's edits, and a button that asks the + * group to sign it as the list's own. + * + * [identifier] is the schema's `d`, for the reason `CuratedSuggestionListRoute` + * gives; [suggestionId] is the suggestion's event id rather than its + * coordinate, because the canonical entry pins the exact version the group + * read -- the NIP's `e` tag -- and the id is what names a version. + */ +@Serializable +data class AcceptCuratedSuggestionRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + val identifier: String, + val suggestionId: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt new file mode 100644 index 00000000..d172ec2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModel.kt @@ -0,0 +1,324 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSuggestionRef +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState + +/** + * One suggestion, taken into the list: the entry as somebody suggested it, + * edited by a member, and proposed as the group's own. + * + * Nothing here is saved. The working copy is one input per value the entry + * has, held on this view model rather than in the composition so that the + * window turning does not throw an edit away, and the button folds it into a + * kind 31890 for the room's quorum to put its key to. The list changes on + * every member's device at once, when a quorum has signed, or not at all. + * + * ### What is edited, and what is kept + * + * Curation is editorial -- the NIP says the curator may fix a year or swap a + * poster on the way through -- so every field the schema's form asks for is + * open. Two things are not. The `d` is kept whatever field writes it, because + * the canonical entry SHOULD land on the suggestion's coordinate so that + * accepting it twice revises one entry rather than making two. And a field the + * schema marks `derived` is kept as the suggester's client filled it in: this + * app derives nothing, and an entry a form never asked for is not one a form + * should let a member invent. + * + * ### Refused before proposed + * + * The button runs the queue's own verifier over the event it is about to + * propose, and refuses with the fields named. A signature is the most expensive + * thing this app does, and a canonical entry the queue would not count -- a + * year outside the schema's range, an option that is not one -- would be a + * quorum spent on an entry no client shows. + */ +class AcceptCuratedSuggestionViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + /** The `d` of the schema whose list the entry goes into. */ + val identifier: String, + /** The event id of the suggestion being taken up. */ + val suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var acceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState by mutableStateOf( + initialAcceptCuratedSuggestionUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + /** + * The working copy: for each field the form asks for, one input per value. + * + * A field that repeats has as many inputs as the suggestion had values, and + * can be given more; every other field has exactly one. Keyed by the + * schema's field name, which is what the entry is keyed by too. + */ + val inputs: SnapshotStateMap> = mutableStateMapOf() + + /** + * Why the last proposal did not go out, or nothing. Set by the button and + * left showing until the next press, since the button is the thing that + * will clear it. + */ + var problems: List by mutableStateOf(emptyList()) + private set + + private var isSeeded = false + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find the group") + return@launch + } + + val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } + if (schema == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that schema") + return@launch + } + + // The same reading the queue screen makes, so what is accepted here + // is what was shown there: newest per coordinate, checked against + // the schema, and marked when the group already has an entry for it. + // An id the reading no longer holds is a version something newer has + // replaced since the row was tapped, and the newer one is what the + // queue now shows. + val events = nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()).first() + val suggestion = CuratedSuggestion.queueOf(schema, events).firstOrNull { it.id == suggestionId } + if (suggestion == null) { + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that suggestion") + return@launch + } + + acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + suggestion = suggestion, + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + + /** + * Fills the working copy from the suggestion, once. + * + * One input per value the suggestion carries for each field the form asks + * for, and one blank input for a field it carries nothing for, so there is + * somewhere to type. Called from the screen rather than from [initiate], + * because the loader is not the only way a loaded state arrives: a preview + * and a layout test both hand one straight in. Runs once, so the effect + * that calls it re-firing does not undo an edit. + */ + fun seedWorkingCopy(state: AcceptCuratedSuggestionUIState.Loaded) { + if (isSeeded) return + isSeeded = true + + val entry = state.suggestion.entry + + editableFields(state).forEach { field -> + val values = entry.values[field.name].orEmpty().ifEmpty { listOf("") } + inputs[field.name] = mutableStateListOf(*values.map { TextFieldState(it) }.toTypedArray()) + } + } + + /** Adds a blank input under [fieldName], for a field that repeats. */ + fun addValue(fieldName: String) { + inputs[fieldName]?.add(TextFieldState()) + } + + /** + * Drops the input at [index] under [fieldName]. The last input stays, blank + * rather than gone, so the field is still there to be typed into. + */ + fun removeValue(fieldName: String, index: Int) { + val values = inputs[fieldName] ?: return + if (index !in values.indices) return + + if (values.size == 1) { + values[0] = TextFieldState() + } else { + values.removeAt(index) + } + } + + /** + * The problems about [fieldName], for the field's own error state. A + * `require-any` rule is filed under its first field, so it shows there. + */ + fun problemsFor(fieldName: String): List = problems.filter { it.field == fieldName } + + /** + * The problems that are about no field on the form: the entry as a whole, + * or a field the form keeps rather than asks for. Shown at the top, since + * there is no input to show them under. + */ + fun problemsAboutTheEntry(state: AcceptCuratedSuggestionUIState.Loaded): List { + val editable = editableFields(state).map { it.name }.toSet() + return problems.filterNot { it.field in editable } + } + + /** + * The entry as the form has it: what was typed for every field the form + * asks for, and what was suggested for every field it keeps. + * + * Keyed by field name and holding every value, which is the shape + * `CuratedEntryEvent.canonicalTemplate` lays out as tags. Blank inputs come + * through and are dropped there; a blank is not a value. + */ + fun draft(state: AcceptCuratedSuggestionUIState.Loaded): Map> { + val suggested = state.suggestion.entry.values + + return state.schema.schema.fields.associate { field -> + field.name to (inputs[field.name]?.map { it.text.toString() } ?: suggested[field.name].orEmpty()) + } + } + + /** + * Opens a session over the entry, or says why not. + * + * Two refusals before anything is proposed and each is the caller's to + * say: an entry the queue would refuse, which is [onInvalid] with the + * reasons left in [problems]; and a proposal that could not be opened, + * which is [onFailure]. Both stay on the form with the edits still in it. + */ + fun proposeEntry( + state: AcceptCuratedSuggestionUIState.Loaded, + onSuccess: (sessionId: String) -> Unit, + onInvalid: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Two sessions over two versions of one + // entry would leave the list decided by whichever quorum finished last. + if (isActionPending.value) return + + val template = state.schema.canonicalTemplate( + values = draft(state), + source = CuratedSuggestionRef.of(state.suggestion.entry), + ) + + problems = state.schema.verifyCanonical(template) + if (problems.isNotEmpty()) { + onInvalid.invoke() + return + } + + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = state.localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a canonical entry for ${state.schema.coordinate()}", 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 = "AcceptCuratedSuggestionViewModel" + + /** The tag of the field the form keeps whatever else it says: the entry's coordinate. */ + private const val IDENTIFIER_TAG = "d" + + /** + * The fields the form opens for editing: the ones the schema's form asks + * for, less the one writing to `d`. See the class note for why those two + * are kept as they were suggested. + */ + fun editableFields(state: AcceptCuratedSuggestionUIState.Loaded): List = + state.schema.schema.formFields().filterNot { it.tag() == IDENTIFIER_TAG } + + /** + * The fields the form shows but does not open: what the suggester's client + * filled in, and what the entry is addressed by. In the schema's order, + * and only the ones the suggestion has a value for, since a kept field + * with nothing in it has nothing to show. + */ + fun keptFields(state: AcceptCuratedSuggestionUIState.Loaded): List { + val editable = editableFields(state).map { it.name }.toSet() + val values = state.suggestion.entry.values + + return state.schema.schema.fields.filter { it.name !in editable && values[it.name].orEmpty().isNotEmpty() } + } + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + suggestionId: String, + initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState = + AcceptCuratedSuggestionUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AcceptCuratedSuggestionViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = identifier, + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt index 383a0ceb..1f811e9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModel.kt @@ -32,9 +32,11 @@ import press.mantra.compose.network.relays.EventPublishTransport import press.mantra.compose.network.relays.NostrPublishResult import press.mantra.compose.network.relays.RelayPool import press.mantra.compose.network.sockets.NostrIncomingMessage +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState @@ -67,9 +69,9 @@ import kotlin.time.Duration.Companion.milliseconds * * [defaultRelaysFor]: the group's own General list where it has agreed one, the * app's publish set where it has not, plus the indexers for a relay list and the - * list's own relays for a schema, minus anything the group has blocked. The - * member can add and remove before sending, and the seeded list is a suggestion - * rather than a rule. + * list's own relays for a schema and for an entry into it, minus anything the + * group has blocked. The member can add and remove before sending, and the + * seeded list is a suggestion rather than a rule. */ class BroadcastGroupSignedEventViewModel( val chatRoomId: String, @@ -176,6 +178,7 @@ class BroadcastGroupSignedEventViewModel( localChatRoom = localChatRoom, signedEvent = signedEvent, relayLists = chatRepository.groupRelayLists(chatRoomId), + schemas = chatRepository.groupCuratedSchemas(chatRoomId), ) } } @@ -189,11 +192,17 @@ class BroadcastGroupSignedEventViewModel( * loaded state straight in. Once and only once, so a member who emptied the * list on purpose does not find it refilled by a state the view model re-emits. */ - fun seedRelays(relayLists: List, kind: Kind, tags: Array>, content: String) { + fun seedRelays( + relayLists: List, + kind: Kind, + tags: Array>, + content: String, + schemas: List = emptyList(), + ) { if (isSeeded) return isSeeded = true - relays.addAll(defaultRelaysFor(relayLists, kind, tags, content)) + relays.addAll(defaultRelaysFor(relayLists, kind, tags, content, schemas)) } fun clearAddFailure() { @@ -377,6 +386,12 @@ class BroadcastGroupSignedEventViewModel( * - A curated schema goes to the relays it names for its own replies, so a * client reading suggestions there finds what they are replying to. * + * - An entry into a curated list goes to the same relays as its schema, + * which the NIP makes a MUST: that is where the list's readers look, + * and where the queue screen reads the entry back to mark the + * suggestion it came from. The entry names its schema by coordinate, + * so the relays are read off the group's own copy of it in [schemas]. + * * And nothing goes to a relay the group has blocked, whatever the other * lists say; that is what a blocked list is for. */ @@ -385,6 +400,7 @@ class BroadcastGroupSignedEventViewModel( kind: Kind, tags: Array> = emptyArray(), content: String = "", + schemas: List = emptyList(), ): List { val general = relayLists.firstOrNull { it.set == GroupRelaySet.General } ?.relays.orEmpty() @@ -399,12 +415,18 @@ class BroadcastGroupSignedEventViewModel( emptyList() } - val schemaRelays = if (kind == CuratedSchemaEvent.KIND) { - runCatching { CuratedSchemaEvent.read(tags, content).relays } - .getOrDefault(emptyList()) - .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } - } else { - emptyList() + val schemaRelays = when (kind) { + CuratedSchemaEvent.KIND -> + runCatching { CuratedSchemaEvent.read(tags, content).relays } + .getOrDefault(emptyList()) + .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } + + CuratedEntryEvent.CANONICAL_KIND -> + schemas.firstOrNull { it.isRepliedToBy(tags) } + ?.schema?.relays.orEmpty() + .mapNotNull { GroupRelaySet.relayUrlOrNull(it) } + + else -> emptyList() } val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt new file mode 100644 index 00000000..381447b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AcceptCuratedSuggestionUIState.kt @@ -0,0 +1,34 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface AcceptCuratedSuggestionUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The list the entry would go into, as the group last signed it. */ + val schema: GroupCuratedSchema, + /** + * The suggestion being taken up, as the queue screen read it: the entry + * keyed by the schema's fields, who suggested it, and whether the group + * already has. The form starts as this, and the canonical entry points + * back at it. + */ + val suggestion: CuratedSuggestion, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the form writable and the button inert, for the reason + * the other editors give: reading and editing cost nothing, and only a + * share-holder can open the session that would sign. + */ + val canSign: Boolean = false, + ): AcceptCuratedSuggestionUIState + + data class Error( + val message: String + ): AcceptCuratedSuggestionUIState + + data object Loading: AcceptCuratedSuggestionUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt index 2f7437d1..277d2c9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/BroadcastGroupSignedEventUIState.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.view.state import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupRelayList sealed interface BroadcastGroupSignedEventUIState { @@ -16,6 +17,12 @@ sealed interface BroadcastGroupSignedEventUIState { * `BroadcastGroupSignedEventViewModel.defaultRelaysFor`. */ val relayLists: List = emptyList(), + /** + * The lists the group curates, for an entry into one of them: the NIP + * says a canonical entry goes to its schema's relays, and the entry + * names its schema by coordinate rather than carrying the relays itself. + */ + val schemas: List = emptyList(), ): BroadcastGroupSignedEventUIState data class Error( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt index 8a9ca840..1949b9ab 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt @@ -3,6 +3,7 @@ package press.mantra.compose.nostr.curated import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -21,6 +22,12 @@ import kotlin.test.assertTrue * schema's field names, with the tags the schema does not define ignored rather * than kept. `director`, `i` and `lang` are on the NIP's example and on no * field of the NIP's schema, and they are not in the reading. + * + * And the write side is pinned against the same example from the other end: + * the canonical entry built from the suggestion's values is the NIP's canonical + * entry, tag for tag, and what the builder writes is what the reader reads + * back -- which is the property that lets the accept form and the queue screen + * agree about an entry without either seeing the other's code. */ class CuratedEntryEventTest { private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" @@ -341,6 +348,163 @@ class CuratedEntryEventTest { assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate") } + // -- the write side ----------------------------------------------------------- + + /** The NIP's example, less the tags its schema does not define, which the builder cannot know to write. */ + private val riseAndRiseAsTheSchemaKnowsIt: List> = riseAndRise + .filterNot { it[0] == "director" || it[0] == "i" || it[0] == "lang" } + .map { it.toList() } + + private val sourceBlock: List> = listOf( + listOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + listOf("e", suggestionId, "", "mention"), + listOf("p", suggester), + ) + + @Test + fun `the canonical entry built from the NIP's suggestion is the NIP's canonical entry`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + + val template = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate, + curator = curator, + values = suggested.values, + source = CuratedSuggestionRef.of(suggested), + createdAt = 1735689720, + ) + + assertEquals(CuratedEntryEvent.CANONICAL_KIND, template.kind) + assertEquals(review, template.content) + // The first two blocks identical to the suggestion's, the third the + // pointer back: the doc's own description of the example. + assertEquals(riseAndRiseAsTheSchemaKnowsIt + sourceBlock, template.tags.map { it.toList() }) + + val preview = CuratedEntryEvent.preview(template, curator) + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) + + // And it reads back as what went in, which is the property the form + // and the queue rely on: the values, the `d`, and where it came from. + val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) + assertEquals(suggested.values, entry.values) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals(CuratedSuggestionRef(suggestionId, "31888:$suggester:imdb:tt2821314", suggester), entry.source) + } + + @Test + fun `an edit on the way through is what is signed, and the d is what was suggested`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + val corrected = suggested.values + mapOf( + "year" to listOf("2015"), + "image" to listOf("https://example.com/a-better-poster.jpg"), + ) + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, corrected, CuratedSuggestionRef.of(suggested)) + val preview = CuratedEntryEvent.preview(template, curator) + + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(preview, schema, coordinate, curator)) + + val entry = assertNotNull(CuratedEntryEvent.canonical(preview, schema, coordinate, curator)) + assertEquals("2015", entry.value("year")) + assertEquals("https://example.com/a-better-poster.jpg", entry.value("image")) + // Same coordinate as before the edit, so re-curating revises rather than adds. + assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) + } + + @Test + fun `a value the schema refuses is refused with its field named, not clipped`() { + val suggested = CuratedEntryEvent.read(suggestion(), schema) + val wrong = suggested.values + mapOf( + "year" to listOf("twenty fourteen"), + "title" to listOf("x".repeat(201)), + "type" to listOf("musical"), + ) + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, wrong) + val problems = CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + + assertEquals( + setOf( + CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong), + CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber), + CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption), + ), + problems.toSet() + ) + + // The reference clips a long title to its cap; this does not, because + // the form shows the problem and the member decides how to shorten it. + assertEquals("x".repeat(201), template.tags.first { it[0] == "title" }[1]) + } + + @Test + fun `a blank is not a value, a field that repeats takes all of them, and one that does not takes the first`() { + val template = CuratedEntryEvent.canonicalTemplate( + schema = schema, + coordinate = coordinate, + curator = curator, + values = mapOf( + "identifier" to listOf("imdb:tt2821314"), + "title" to listOf(" The Rise and Rise of Bitcoin ", "A second title nobody asked for"), + "type" to listOf("documentary"), + "year" to listOf(" "), + "watchUrl" to listOf("http://bitcoindoc.com/", "", "https://mirror.example/watch"), + "description" to listOf(""), + ), + ) + + val tags = template.tags.map { it.toList() } + + assertEquals(listOf(listOf("title", "The Rise and Rise of Bitcoin")), tags.filter { it[0] == "title" }) + assertEquals(emptyList(), tags.filter { it[0] == "year" }) + assertEquals( + listOf( + listOf("r", "http://bitcoindoc.com/", "watch"), + listOf("r", "https://mirror.example/watch", "watch"), + ), + tags.filter { it[0] == "r" } + ) + assertEquals("", template.content) + assertEquals( + emptyList(), + CuratedEntryEvent.verifyCanonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + ) + } + + @Test + fun `an entry nobody suggested has no source block, and the root carries the schema's first relay`() { + val values = CuratedEntryEvent.read(suggestion(), schema).values + + val template = CuratedEntryEvent.canonicalTemplate(schema, coordinate, curator, values, source = null) + val tags = template.tags.map { it.toList() } + + assertEquals(listOf(listOf("a", coordinate, "wss://ephemeral.mantra.press", "root")), tags.filter { it[0] == "a" }) + assertEquals(listOf(listOf("p", curator)), tags.filter { it[0] == "p" }) + assertEquals(emptyList(), tags.filter { it[0] == "e" }) + + val entry = assertNotNull( + CuratedEntryEvent.canonical(CuratedEntryEvent.preview(template, curator), schema, coordinate, curator) + ) + assertNull(entry.source) + } + + @Test + fun `only a suggestion can be pointed at`() { + val theListsOwn = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) + + assertFailsWith { CuratedSuggestionRef.of(theListsOwn) } + } + + @Test + fun `the headline needs no schema`() { + val entry = CuratedEntryEvent.headline(canonical()) + + assertEquals("The Rise and Rise of Bitcoin", entry.title) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals(suggester, entry.source?.pubKey) + assertEquals(emptyMap(), entry.values) + } + private fun suggestion( tags: Array> = riseAndRise, content: String = review, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 2a699980..2de50968 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,5 +1,6 @@ package press.mantra.compose.text +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -80,6 +81,39 @@ class ProposedEventTest { assertEquals("Films worth translating · closed · 2 fields", summary.detail) } + @Test + fun `a curated entry is said by its title and who suggested it`() { + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val summary = ProposedEvent.summarize( + event( + CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf( + arrayOf("d", "imdb:tt2821314"), + arrayOf("title", "The Rise and Rise of Bitcoin"), + arrayOf("year", "2014"), + arrayOf("a", "31889:${"7".repeat(64)}:bitcoin.mov", "", "root"), + arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + arrayOf("p", suggester), + ), + content = "Follows the early Bitcoin community." + ) + ) + + assertEquals("Curated entry", summary.label) + assertEquals("The Rise and Rise of Bitcoin · suggested by eebb74ab", summary.detail) + + // An entry the group added itself has nobody to credit. + val unprompted = ProposedEvent.summarize( + event( + CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("d", "magic-money"), arrayOf("title", "Magic Money")), + content = "" + ) + ) + assertEquals("Magic Money", unprompted.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. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt new file mode 100644 index 00000000..860aef0e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/AcceptCuratedSuggestionScreenJvmTest.kt @@ -0,0 +1,216 @@ +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.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.test.Test +import kotlin.time.Instant + +/** + * What the accept form puts on the screen, for a schema it has never seen. + * + * Three things worth pinning. **The inputs are the schema's**: one per field + * the form asks for, labelled as the schema labels them and seeded with what + * was suggested, with an enum as its options rather than a box to misspell + * one in. **What is kept is shown, not hidden**: the identifier and the + * derived fields are on the screen under their own heading, so what the + * member sees is what the group will sign. And **a refusal is drawn under the + * input it is about**, since the button doing nothing is indistinguishable + * from a missed tap. + */ +@OptIn(ExperimentalTestApi::class) +class AcceptCuratedSuggestionScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + domain = "films.example", + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), + CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), + CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), + CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG, hint = "Why the group should watch it")), + ), + relays = listOf("wss://relay.one.example") + ).normalized() + ) + + private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( + schema, + listOf( + LocalNostrEvent( + nostrEvent = NostrEvent( + id = "1".repeat(64), + pubKey = bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("year", "2018"), + arrayOf("type", "documentary"), + arrayOf("r", "https://example.com/watch", "watch"), + arrayOf("t", "bitcoin"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + profile = null + ) + ) + ).single() + + private fun state(canSign: Boolean = true, isCurated: Boolean = false) = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + suggestion = suggestion.copy(isCurated = isCurated), + canSign = canSign + ) + + @Test + fun `the inputs are the schema's, seeded from the suggestion, and what is kept is shown`() = render(state()) { + // Twice: the heading, and the title input seeded with it. + onAllNodesWithText("Magic Money").assertCountEquals(2) + onNodeWithText("Suggested by ${bob.take(8)} on ", substring = true).assertIsDisplayed() + onNodeWithText("Take this suggestion into films.example", substring = true).assertIsDisplayed() + + // The schema's labels over its values: a text field, a number, and the + // enum as chips with the suggested one among them. + onNodeWithText("Year").assertIsDisplayed() + onNodeWithText("2018").assertIsDisplayed() + onNodeWithText("Type").assertIsDisplayed() + onNodeWithText("movie").assertIsDisplayed() + onNodeWithText("documentary").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertIsDisplayed() + onNodeWithText("Follows the early community.").assertIsDisplayed() + // The schema's hint under the input it is for. + onNodeWithText("Why the group should watch it").assertIsDisplayed() + // A field that repeats can be given another value. + onNodeWithText("Add another").assertIsDisplayed() + + // Kept, and said to be: the identifier and the derived hashtags. + onNodeWithText("Kept from the suggestion").assertIsDisplayed() + onNodeWithText("Identifier").assertIsDisplayed() + onNodeWithText("magic-money").assertIsDisplayed() + onNodeWithText("Hashtags").assertIsDisplayed() + onNodeWithText("bitcoin").assertIsDisplayed() + + onNodeWithText("Propose entry").assertIsDisplayed() + onAllNodesWithText("The group already has an entry for this", substring = true).assertCountEquals(0) + } + + @Test + fun `a refusal is drawn under the input it is about`() = render(state()) { + // The suggested year is replaced with one the schema's range refuses, + // by way of the field: the test types where a member would. + onNodeWithText("2018").performTextReplacement("1850") + onNodeWithText("Propose entry").performClick() + + onNodeWithText("At least 1900.").assertIsDisplayed() + } + + @Test + fun `a device without a share sees why the button is inert`() = render(state(canSign = false)) { + onNodeWithText("This group has no shared key, so it cannot sign an entry", substring = true).assertIsDisplayed() + onNodeWithText("Propose entry").assertIsNotEnabled() + } + + @Test + fun `a suggestion the group already took up says so before the button`() = render(state(isCurated = true)) { + onNodeWithText("The group already has an entry for this", substring = true).assertIsDisplayed() + } + + /** + * The screen at a phone's width and a window tall enough to compose the + * whole of it. The state is passed in, so the NO_OP repositories are never + * asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: AcceptCuratedSuggestionUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2400) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + AcceptCuratedSuggestionScreen( + activeUserPublicKey = ada, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + suggestionId = suggestion.id, + initialAcceptCuratedSuggestionUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateBack = {}, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } + } + } + + assertions() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt index 62deff65..db5ba06c 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runDesktopComposeUiTest import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GroupSignedEvent @@ -184,21 +185,23 @@ class CuratedSuggestionListScreenJvmTest { } @Test - fun `the sheet has every field the entry answered, the link included, and the event`() = + fun `the sheet has every field the entry answered, the link included, and the way in`() = runDesktopComposeUiTest(width = 400, height = 2000) { + var accepted = false + setContent { MantraTheme { ProvideSnackbarHost { CuratedSuggestionSheetContent( - suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), - schema = schema.schema + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = false), + schema = schema.schema, + onAccept = { accepted = true } ) } } } onNodeWithText("Magic Money").assertIsDisplayed() - onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() // As the schema labels them, in the schema's order. onNodeWithText("Year").assertIsDisplayed() @@ -208,9 +211,32 @@ class CuratedSuggestionListScreenJvmTest { onNodeWithText("Review").assertIsDisplayed() onNodeWithText("Follows the early community.").assertIsDisplayed() - // The event, whole, and a way to take it somewhere. - onNodeWithText("\"kind\":31888", substring = true).assertIsDisplayed() - onNodeWithText("Copy the suggestion").assertIsDisplayed() + // The one thing to do with it, and not the event: the form shows + // the entry as fields, which is how the group will sign it. + onAllNodesWithText("\"kind\":31888", substring = true).assertCountEquals(0) + onAllNodesWithText("Copy the suggestion").assertCountEquals(0) + onNodeWithText("Accept this suggestion").assertIsDisplayed().performClick() + assertTrue(accepted, "the button did not open the form") + } + + @Test + fun `a suggestion the group already took up offers a revision, not a second acceptance`() = + runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CuratedSuggestionSheetContent( + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), + schema = schema.schema, + onAccept = {} + ) + } + } + } + + onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() + onNodeWithText("Revise the list's entry").assertIsDisplayed() + onAllNodesWithText("Accept this suggestion").assertCountEquals(0) } private fun suggestion( @@ -294,7 +320,8 @@ class CuratedSuggestionListScreenJvmTest { initialCuratedSuggestionListUIState = uiState, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, - onNavigateBack = {} + onNavigateBack = {}, + onNavigateToRoute = {} ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt new file mode 100644 index 00000000..97f4d2cc --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/AcceptCuratedSuggestionViewModelJvmTest.kt @@ -0,0 +1,412 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedEntryProblem +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The decisions the accept form makes that nothing else can check. + * + * **What is opened and what is kept.** The form asks for what the schema's + * form asks for, and keeps the `d` and the derived fields as they were + * suggested; getting that wrong in one direction lets a member invent a + * hashtag the app was supposed to derive, and in the other loses the + * coordinate that makes a re-curation a revision. + * + * **What the button refuses.** An entry the queue would not count is refused + * with the field named, before a quorum is asked for it -- and the entry it + * does propose is the one the queue screen will read back, source and all. + * + * **What the loader finds.** The suggestion is looked up by the queue's own + * reading, so what is accepted is what was shown. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AcceptCuratedSuggestionViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type", config = CuratedFieldConfig(options = listOf("movie", "documentary"))), + CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, label = "Watch URL", config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)), + CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, label = "IMDb URL", config = CuratedFieldConfig(tag = "r", marker = "imdb")), + CuratedField("hashtags", CuratedFieldType.Token, isRequired = false, label = "Hashtags", config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)), + CuratedField("review", CuratedFieldType.LongText, isRequired = false, label = "Review", config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)), + ), + requireAny = listOf(listOf("watchUrl", "imdbUrl")), + relays = listOf("wss://relay.one.example"), + ).normalized(), + ) + + private val localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ) + + /** *Magic Money* as Bob suggested it: two watch links, and hashtags his client derived. */ + private val suggestedEvent = NostrEvent( + id = "1".repeat(64), + pubKey = bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("year", "2018"), + arrayOf("type", "documentary"), + arrayOf("r", "https://example.com/watch", "watch"), + arrayOf("r", "https://mirror.example/watch", "watch"), + arrayOf("t", "bitcoin"), + arrayOf("t", "documentary"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + private val suggestion: CuratedSuggestion = CuratedSuggestion.queueOf( + schema, + listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null)) + ).single() + + private fun loaded(canSign: Boolean = true) = AcceptCuratedSuggestionUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + suggestion = suggestion, + canSign = canSign, + ) + + /** What was asked of the signing repository, and the session it answers with. */ + private class RecordingSigning(private val session: FrostSigningSession?) : + FrostSigningRepository by FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY { + var kind: Kind? = null + var tags: Array>? = null + var content: String? = null + + override suspend fun canSign(chatRoomId: String): Boolean = true + + override suspend fun proposeSigning( + localChatRoom: LocalChatRoom, + userPublicKey: HexKey, + kind: Kind, + tags: Array>, + content: String + ): FrostSigningSession? { + this.kind = kind + this.tags = tags + this.content = content + return session + } + } + + private fun session() = FrostSigningSession( + id = "session-1", + chatRoomId = chatRoomId, + coordinatorPublicKey = ada, + userPublicKey = ada, + dkgSessionId = "dkg-1", + threshold = 2, + participantCount = 3, + signerId = 1, + ) + + private fun viewModel( + signing: FrostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + nostrRepository: NostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository: ChatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + suggestionId: String = suggestion.id, + ) = AcceptCuratedSuggestionViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + suggestionId = suggestionId, + initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loading, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = signing, + ) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the form opens what the schema asks for, one input per value, and keeps the rest`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + // Not the identifier -- it writes to `d` -- and not the derived hashtags. + assertEquals( + listOf("title", "year", "type", "watchUrl", "imdbUrl", "review"), + AcceptCuratedSuggestionViewModel.editableFields(state).map { it.name } + ) + assertEquals( + listOf("identifier", "hashtags"), + AcceptCuratedSuggestionViewModel.keptFields(state).map { it.name } + ) + + assertEquals(listOf("Magic Money"), viewModel.inputs["title"]?.map { it.text.toString() }) + assertEquals(listOf("2018"), viewModel.inputs["year"]?.map { it.text.toString() }) + // A field that repeats gets an input per value the suggestion carried. + assertEquals( + listOf("https://example.com/watch", "https://mirror.example/watch"), + viewModel.inputs["watchUrl"]?.map { it.text.toString() } + ) + // A field the suggestion has nothing for gets one blank to type into. + assertEquals(listOf(""), viewModel.inputs["imdbUrl"]?.map { it.text.toString() }) + assertNull(viewModel.inputs["identifier"], "the identifier was opened for editing") + assertNull(viewModel.inputs["hashtags"], "a derived field was opened for editing") + } + + @Test + fun `seeding again leaves an edit alone`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + + viewModel.seedWorkingCopy(state) + + assertEquals("2019", viewModel.inputs["year"]!![0].text.toString()) + } + + @Test + fun `the draft is what was typed plus what was kept, and the d is the suggestion's`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + viewModel.addValue("watchUrl") + viewModel.inputs["watchUrl"]!![2].setTextAndPlaceCursorAtEnd("https://third.example/watch") + viewModel.removeValue("watchUrl", 0) + + val draft = viewModel.draft(state) + + assertEquals(listOf("magic-money"), draft["identifier"]) + assertEquals(listOf("2019"), draft["year"]) + assertEquals(listOf("https://mirror.example/watch", "https://third.example/watch"), draft["watchUrl"]) + assertEquals(listOf("bitcoin", "documentary"), draft["hashtags"]) + // A blank input comes through; the builder is what drops it. + assertEquals(listOf(""), draft["imdbUrl"]) + } + + @Test + fun `removing the last input leaves a blank one rather than nothing`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.removeValue("year", 0) + + assertEquals(listOf(""), viewModel.inputs["year"]?.map { it.text.toString() }) + } + + @Test + fun `an entry the queue would refuse is refused with the field named, and nothing is proposed`() { + val state = loaded() + val signing = RecordingSigning(session()) + val viewModel = viewModel(signing) + viewModel.seedWorkingCopy(state) + + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("1850") + viewModel.inputs["type"]!![0].setTextAndPlaceCursorAtEnd("musical") + + var invalid = false + viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { invalid = true }, onFailure = { error("failed") }) + + assertTrue(invalid) + assertEquals(listOf(CuratedEntryProblem.Reason.BelowMinimum), viewModel.problemsFor("year").map { it.reason }) + assertEquals(listOf(CuratedEntryProblem.Reason.NotAnOption), viewModel.problemsFor("type").map { it.reason }) + assertEquals(emptyList(), viewModel.problemsAboutTheEntry(state)) + assertNull(signing.kind, "a refused entry was proposed anyway") + assertFalse(viewModel.isActionPending.value) + } + + @Test + fun `a require-any rule the edit broke is filed under its first field`() { + val state = loaded() + val viewModel = viewModel() + viewModel.seedWorkingCopy(state) + + viewModel.removeValue("watchUrl", 0) + viewModel.removeValue("watchUrl", 0) + + viewModel.proposeEntry(state, onSuccess = {}, onInvalid = {}, onFailure = {}) + + assertEquals(listOf(CuratedEntryProblem.Reason.RequireAnyUnmet), viewModel.problemsFor("watchUrl").map { it.reason }) + } + + @Test + fun `the entry proposed is the canonical one the queue will read back`() { + val state = loaded() + val signing = RecordingSigning(session()) + val viewModel = viewModel(signing) + viewModel.seedWorkingCopy(state) + viewModel.inputs["year"]!![0].setTextAndPlaceCursorAtEnd("2019") + + var sessionId: String? = null + viewModel.proposeEntry(state, onSuccess = { sessionId = it }, onInvalid = { error("invalid") }, onFailure = { error("failed") }) + + runBlocking { + withTimeout(5_000) { + while (sessionId == null) kotlinx.coroutines.delay(10) + } + } + + assertEquals("session-1", sessionId) + assertEquals(CuratedEntryEvent.CANONICAL_KIND, signing.kind) + assertEquals("Follows the early community.", signing.content) + + val tags = assertNotNull(signing.tags).map { it.toList() } + assertTrue(listOf("d", "magic-money") in tags, "the entry lost the suggestion's d") + assertTrue(listOf("year", "2019") in tags, "the edit was not signed") + assertTrue(listOf("t", "bitcoin") in tags && listOf("t", "documentary") in tags, "the derived hashtags were dropped") + assertTrue(listOf("a", schema.coordinate(), "wss://relay.one.example", "root") in tags, "the entry does not reply to the list") + assertTrue(listOf("a", "31888:$bob:magic-money", "", "mention") in tags, "the entry does not point at the suggestion") + assertTrue(listOf("e", suggestion.id, "", "mention") in tags, "the entry does not pin the version read") + assertTrue(listOf("p", bob) in tags, "the suggester is not credited") + + // And it is what the queue counts as the group's entry for that suggestion. + val signed = NostrEvent( + id = "2".repeat(64), + pubKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = signing.tags!!, + content = signing.content!!, + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_900), + ) + val queue = CuratedSuggestion.queueOf( + schema, + listOf(LocalNostrEvent(suggestedEvent, null), LocalNostrEvent(signed, null)) + ) + assertTrue(queue.single().isCurated, "the queue does not count the proposed entry as curating the suggestion") + } + + @Test + fun `a session that could not be opened is a failure, and the form stays`() { + val state = loaded() + val viewModel = viewModel(RecordingSigning(session = null)) + viewModel.seedWorkingCopy(state) + + var failed = false + viewModel.proposeEntry(state, onSuccess = { error("proposed") }, onInvalid = { error("invalid") }, onFailure = { failed = true }) + + runBlocking { + withTimeout(5_000) { + while (!failed) kotlinx.coroutines.delay(10) + } + } + + assertEquals(emptyList(), viewModel.problems) + assertEquals("Magic Money", viewModel.inputs["title"]!![0].text.toString()) + } + + @Test + fun `the loader finds the suggestion by the queue's reading, and says when it cannot`() { + val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> = + flowOf(listOf(LocalNostrEvent(nostrEvent = suggestedEvent, profile = null))) + } + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) + } + + val found = viewModel(RecordingSigning(null), nostrRepository, chatRepository) + found.initiate() + val loaded = await { found.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Loaded } + assertEquals("Magic Money", loaded.suggestion.entry.title) + assertTrue(loaded.canSign) + + val missing = viewModel(RecordingSigning(null), nostrRepository, chatRepository, suggestionId = "9".repeat(64)) + missing.initiate() + val error = await { missing.acceptCuratedSuggestionUIState as? AcceptCuratedSuggestionUIState.Error } + assertEquals("Couldn't find that suggestion", error.message) + } + + private fun await(read: () -> T?): T = runBlocking { + withTimeout(5_000) { + var value = read() + while (value == null) { + kotlinx.coroutines.delay(10) + value = read() + } + value + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt index 64b36d13..9ef230c7 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/BroadcastGroupSignedEventViewModelJvmTest.kt @@ -26,7 +26,11 @@ import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility import press.mantra.compose.repository.ChatRepository import press.mantra.compose.ui.view.model.BroadcastGroupSignedEventViewModel.RelayOutcome import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState @@ -232,6 +236,52 @@ class BroadcastGroupSignedEventViewModelJvmTest { assertEquals(listOf(one, three), seeded) } + @Test + fun `an entry into a curated list goes to the list's relays, found by the coordinate it replies to`() { + val relayLists = agreed(GroupRelaySet.General to listOf(GroupRelay(one))) + + val films = GroupCuratedSchema( + signedEvent = relaySignedEvent(GroupRelaySet.General).copy(kind = CuratedSchemaEvent.KIND), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + relays = listOf(three.url), + ).normalized(), + ) + val books = films.copy(schema = films.schema.copy(identifier = "books", relays = listOf(two.url))) + + val seeded = BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists = relayLists, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("a", films.coordinate(), "", "root"), + ), + content = "", + schemas = listOf(books, films), + ) + + // The group's relay, then the relays of the list replied to -- and not + // the other list's: the NIP sends an entry where its own schema says. + assertEquals(listOf(one, three), seeded) + + // An entry whose schema this device does not hold goes where the group + // publishes, since there is nothing else to go on. + assertEquals( + listOf(one), + BroadcastGroupSignedEventViewModel.defaultRelaysFor( + relayLists = relayLists, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("a", "31889:${"0".repeat(64)}:elsewhere", "", "root")), + schemas = listOf(books, films), + ) + ) + } + @Test fun `seeding happens once, so an emptied list stays empty`() { val viewModel = viewModel()