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