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