diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index e415143a..16d2b935 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -457,4 +457,106 @@
Couldn't ask the group to sign this post.
Posted %1$s
A reply
+ Curated schemas
+ This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate.
+ Add schema
+ Edit schema
+ Propose schema
+ Fields: %1$s
+ Public
+ Closed
+ Private
+ Anyone may suggest an entry, and anyone may read the list.
+ Only the group and the suggesters below may suggest. Anyone may read the list.
+ Only the group and the suggesters below may suggest, and clients show the list to them alone. Relays are open, so this is a convention rather than a secret.
+ What an entry in this list may contain, and who may suggest one
+ The group signs a curated schema, so it takes a quorum. Suggestions reply to it, and only the group can accept them into the list.
+ This group has no shared key, so it cannot sign a curated schema. Run a shared key ceremony first.
+ Couldn't ask the group to sign this schema.
+ Identifier
+ eg. bitcoin.mov
+ The identifier names this list in every reply to it, so it can't change. Add a schema to start another list.
+ Title
+ eg. bitcoin.mov suggestion
+ Description
+ eg. Fields for a bitcoin.mov entry: a film, with at least one link to watch it
+ Visibility
+ Domain
+ eg. example.com
+ A domain replaces the name wherever the list is shown. It's a claim rather than proof, so only set one the group controls.
+ Fields
+ Add field
+ Required
+ Optional
+ Derived
+ At least one of
+ Add rule
+ Pick two or more fields, at least one of which an entry must have. A rule with fewer than two is dropped.
+ Suggesters
+ Pubkeys besides the group that may suggest to this list.
+ npub or hex pubkey
+ Add suggester
+ That isn't a pubkey. Paste an npub or 64 hex characters.
+ That pubkey is already a suggester.
+ Where suggestions and entries are published to and read from. Signed into the schema, so a reply can't be sent anywhere else.
+ No relays named, so a client will pick its own.
+ New field
+ Edit field
+ Field name
+ eg. watchUrl
+ Label
+ eg. Watch / reference url
+ Placeholder
+ eg. https://youtube.com/watch?v=…
+ Type
+ An entry must have this field
+ Writes to tag
+ Defaults to the field name. "content" means the event's body.
+ Marker
+ eg. watch
+ The tag's third element, which tells fields sharing a tag apart.
+ Maximum characters
+ Maximum value
+ Minimum value
+ Options
+ One per line, or separated by commas.
+ https only
+ May appear more than once
+ Pattern
+ A regular expression the whole value must match.
+ Filled in by the app, never asked for
+ Hint
+ Helper text under the input.
+ Done
+ Remove field
+ A field needs a name.
+ A field name is one word, with no spaces.
+ A field named that already exists.
+ An identifier is required.
+ The identifier must be at most %1$s characters.
+ A title is required.
+ The title must be at most %1$s characters.
+ A name is required.
+ The name must be at most %1$s characters.
+ A description is required.
+ The description must be at most %1$s characters.
+ Say who may suggest to this list.
+ The picture must be an https url.
+ That isn't a domain name.
+ A schema needs at least one field.
+ One of the relays isn't a relay address.
+ eg. 2100
+ eg. 200
+ eg. 1900
+ eg. movie, documentary, short
+ eg. [A-Za-z]{2,8}
+ Text
+ Long text
+ Token
+ Url
+ Image
+ One of a list
+ Year
+ Duration in seconds
+ Number
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 020b3c39..539ee32a 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
@@ -22,7 +22,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
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.CuratedSchemaEvent
import press.mantra.compose.nostr.GroupRelaySet
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import press.mantra.compose.nostr.dkg.DkgRitualEvents
@@ -434,10 +436,22 @@ data class ChatMessage(
*/
const val TYPE_GROUP_POST_SIGNED = "groupPostSigned"
+ /**
+ * The group signed the schema of a list it curates.
+ *
+ * A system line like the other three, and it names the list rather than
+ * describing the schema: what a reader of the room needs to know is that
+ * the group now curates -- or has changed the form for -- a list called
+ * so-and-so, and the schema itself is on the group's screen where its
+ * fields can be read.
+ */
+ const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned"
+
val GROUP_IDENTITY_TYPES = setOf(
TYPE_GROUP_PROFILE_SIGNED,
TYPE_GROUP_RELAYS_SIGNED,
TYPE_GROUP_POST_SIGNED,
+ TYPE_GROUP_SCHEMA_SIGNED,
)
/**
@@ -1535,6 +1549,36 @@ data class ChatMessage(
)
}
+ // The group defining a list it curates: the kind 31889 that
+ // suggestions from anyone reply to and only the group's own key can
+ // accept into the list.
+ //
+ // Verified and parsed, and null on either failing, the way the
+ // profile arm is. A rumor of this kind from a member is not the
+ // group curating anything, and a schema the room signed but that no
+ // client could act on -- no visibility, no fields -- is not worth a
+ // line saying it did. `GroupCuratedSchema` refuses the same event
+ // on the group's screen, so the transcript and the screen agree.
+ CuratedSchemaEvent.KIND -> {
+ val curated = GroupCuratedSchema.of(
+ signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId),
+ chatRoomId = groupId,
+ ) ?: return null
+
+ ChatMessage(
+ giftWrapPayloadId = null,
+ messageType = TYPE_GROUP_SCHEMA_SIGNED,
+ marmotGroupEventId = marmotGroupEventId,
+ marmotInnerEventId = marmotInnerEventId,
+ senderPublicKey = senderPublicKey,
+ isUserMessage = isUserMessage,
+ chatRoomId = groupId,
+ createdAt = createdAt,
+ content = "The group signed the curated schema for " +
+ curated.schema.displayName()
+ )
+ }
+
else -> unsupported()
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
index 277f3755..01acecfd 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt
@@ -10,7 +10,9 @@ import press.mantra.compose.managers.GroupKeyStateManager
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.nostr.GroupNostrProfile
+import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupPost
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.database.model.ChatMessage
@@ -170,6 +172,17 @@ class DatabaseChatRepository(
emptyList()
}
+ override suspend fun groupCuratedSchemas(chatRoomId: String): List = try {
+ GroupCuratedSchema.newestPerListAmong(
+ signedEvents = database.groupSignedEventDao()
+ .getByChatRoomIdAndKind(chatRoomId, CuratedSchemaEvent.KIND),
+ chatRoomId = chatRoomId,
+ )
+ } catch (e: Throwable) {
+ logger.e("Error reading the curated schemas of $chatRoomId", e)
+ emptyList()
+ }
+
override suspend fun canSign(chatRoomId: String): Boolean = try {
FrostSigningManager.canSign(database, chatRoomId)
} catch (e: Throwable) {
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt
new file mode 100644
index 00000000..40efc723
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt
@@ -0,0 +1,107 @@
+package press.mantra.compose.nostr
+
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.nostr.curated.CuratedSchema
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
+import kotlin.time.Instant
+
+/**
+ * A curated schema the group signed: a list it curates, as the group defined it.
+ *
+ * Kind 31889 -- see [CuratedSchemaEvent] -- authored by the room's own key. That
+ * makes the group the curator of the list the schema describes: anyone may reply
+ * to it with a suggestion, and only an event signed by the same key counts as the
+ * list itself. For a group that means the same thing everything else on its screen
+ * means: a quorum decides what goes in.
+ *
+ * ### Several per group, one per identifier
+ *
+ * Unlike a profile, of which a group has one, a group may curate several lists,
+ * and each is its own coordinate `31889::`. So the reading is per
+ * identifier: among the events sharing a `d` the newest wins, the way it does for
+ * a profile, because the kind is addressable and re-signing one is how a schema
+ * is edited. Two different identifiers are two lists and both are shown.
+ *
+ * Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the
+ * reasons `GroupNostrProfile` gives at length: a group-signed event is not a
+ * `NostrEvent`, and only an event the room itself signed is the group speaking.
+ *
+ * ### Nothing here has reached a relay
+ *
+ * The same caveat as the profile, the relay lists and the posts. A schema is the
+ * one statement here that exists to be *replied to* by strangers, so until it is
+ * published the list it defines has no queue -- the relays it names say where
+ * suggestions would go, and nothing yet puts the schema there for them to find.
+ */
+data class GroupCuratedSchema(
+ /** The group's statement, whole, with the signature that makes it one. */
+ val signedEvent: GroupSignedEvent,
+ /** What the statement says, already checked and normalized. */
+ val schema: CuratedSchema,
+) {
+ /** The group's nostr identity, which for a derived room is also its id. */
+ val publicKey: HexKey get() = signedEvent.publicKey
+
+ /** When the group signed it, which is what decides the newest of two. */
+ val signedAt: Instant get() = signedEvent.createdAt
+
+ /** The schema's `d`, and the last part of its coordinate. */
+ val identifier: String get() = schema.identifier
+
+ /** What a suggestion's `a` root names to reply to this list. */
+ fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier)
+
+ companion object {
+ /**
+ * The reading of one signed event, or null when it is not one of these.
+ *
+ * Three ways to be null and they are all the same refusal: the wrong
+ * kind, a signature that does not check out as [chatRoomId]'s, or tags
+ * that do not make a usable schema. A caller gets a schema the group
+ * really signed and a client could really act on, or gets nothing.
+ */
+ fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupCuratedSchema? {
+ if (signedEvent.kind != CuratedSchemaEvent.KIND) return null
+ if (!signedEvent.verifies()) return null
+
+ val schema = CuratedSchemaEvent.parse(signedEvent.toEvent()) ?: return null
+
+ return GroupCuratedSchema(signedEvent = signedEvent, schema = schema)
+ }
+
+ /**
+ * The newest schema per identifier [chatRoomId] signed among
+ * [signedEvents], most recently signed list first.
+ *
+ * Newest per identifier because the kind is addressable and an edit is a
+ * newer event under the same `d`; newest list first so that an edit
+ * surfaces, and the id breaking every tie so that two devices reading the
+ * same events in different orders show the same thing.
+ */
+ fun newestPerListAmong(
+ signedEvents: List,
+ chatRoomId: String,
+ ): List =
+ signedEvents
+ .mapNotNull { of(it, chatRoomId) }
+ .groupBy { it.identifier }
+ .values
+ .map { versions ->
+ versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id }))
+ }
+ .sortedWith(
+ compareByDescending { it.signedAt }
+ .thenByDescending { it.signedEvent.id }
+ )
+
+ /**
+ * The event to ask the group to sign for [schema].
+ *
+ * Built from the schema alone -- see [CuratedSchemaEvent.template] for
+ * why an edit does not carry the last event's tags forward.
+ */
+ fun template(schema: CuratedSchema): EventTemplate<*> = CuratedSchemaEvent.template(schema)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt
new file mode 100644
index 00000000..cc6e7d6d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt
@@ -0,0 +1,475 @@
+package press.mantra.compose.nostr.curated
+
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip01Core.core.Kind
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import kotlinx.serialization.json.put
+import press.mantra.compose.network.serialization.CommonJson
+
+/**
+ * The definition of a curated list: what an entry may contain, who may suggest
+ * one, and whose list it is.
+ *
+ * This is the document the other two curated kinds are checked against. A
+ * suggestion (kind 31888) is a reply to it that has to satisfy its fields; a
+ * canonical entry (kind 31890) is a suggestion its curator republished under
+ * their own key. The list is the set of canonical entries and the suggestions
+ * are its queue -- and this is the form both are filled in against, published
+ * so that another client can render the same form without shipping this code.
+ *
+ * The curator's pubkey is the namespace. `31889::` names
+ * one schema and no other, so nothing global is claimed and nothing can be
+ * squatted. Here the curator is a group: the schema is signed by the room's own
+ * key, like its profile and its posts, and it is the group -- by quorum -- that
+ * accepts a suggestion into the list.
+ *
+ * The protocol is written up in `bitcoin.mov/docs/NIP.md`, and the shape here
+ * follows its reference module tag for tag so that a schema this app signs is
+ * one that site's form can be driven by, and one it publishes reads here.
+ *
+ * ### What is checked, and where
+ *
+ * [problems] is the list of reasons a schema is unusable, and it is the same
+ * list whether the schema was typed into the editor or read off a relay: the
+ * NIP's rejection rules, no more. A reader rejects rather than repairs --
+ * relays carry malformed and hostile events, and a half-parsed form is worse
+ * than a missing one -- and the editor refuses to propose what a reader would
+ * refuse to show.
+ *
+ * [normalized] is the one repair the NIP requires. Every list has a field
+ * writing to `d` and one writing to `title`, both required, and a schema that
+ * omits or relaxes either gets them put back -- so no schema, wherever it came
+ * from, can talk a client into accepting untitled or unaddressable entries.
+ */
+data class CuratedSchema(
+ /** The `d` tag: the schema's identifier, and the last part of its coordinate. */
+ val identifier: String,
+ /** Labels the event -- "bitcoin.mov suggestion". */
+ val title: String,
+ /** The list's identity, what people call it -- "bitcoin.mov". */
+ val name: String,
+ /** What the list is for. Mirrored into the event's content for generic clients. */
+ val description: String,
+ /**
+ * Who may suggest, or null when the schema does not say.
+ *
+ * Null is never defaulted away: a list that does not say who may suggest to
+ * it is not one a client should act on, so an absent or unrecognised
+ * visibility is a [CuratedSchemaProblem.VisibilityMissing] rather than a
+ * public list by accident. The editor always has one; only a schema read off
+ * a relay can be missing it.
+ */
+ val visibility: CuratedVisibility?,
+ /** An https image for the list, or null. */
+ val picture: String? = null,
+ /**
+ * A hostname the list publishes under, or null. Shown in place of [name]
+ * wherever the list is named, which is why it is a claim worth being careful
+ * with: anyone can put any domain in a tag. See [displayName].
+ */
+ val domain: String? = null,
+ /** The kind suggestions are published under. There is only one. */
+ val suggestionKind: Kind = CuratedSchemaEvent.SUGGESTION_KIND,
+ val fields: List = emptyList(),
+ /**
+ * Groups of field names of which at least one must be present on an entry.
+ *
+ * How a list says "a watch link or an IMDb link" without making either one
+ * required. A group of fewer than two names says nothing and is dropped.
+ */
+ val requireAny: List> = emptyList(),
+ /**
+ * Pubkeys allowed to suggest on a closed or private list, besides the
+ * curator. The `p` tags. Meaningless on a public list, where anyone may.
+ */
+ val suggesters: List = emptyList(),
+ /**
+ * Where the list lives: the relays suggestions and canonical entries are
+ * published to and read from. Signed into the event so a client that finds
+ * the schema anywhere knows where a reply belongs and cannot be sent
+ * elsewhere by an unsigned config file.
+ */
+ val relays: List = emptyList(),
+) {
+ /**
+ * What to call the list on screen: the domain when it has one, else the
+ * name. The domain is the stronger identity because it is the one part of
+ * this that can be checked against the outside world, NIP-05 style -- and
+ * nothing here does check it, so it is a label rather than a badge.
+ */
+ fun displayName(): String = domain ?: name
+
+ /** Fields a form should prompt for. Derived ones are filled in by the client. */
+ fun formFields(): List = fields.filterNot { it.config.derived == true }
+
+ /**
+ * The same schema with the two mandatory fields forced into place.
+ *
+ * A field writing to `d` and one writing to `title`, both required, in that
+ * order at the front when they were missing. A schema that already has them
+ * is unchanged apart from the requirement, so running this twice is running
+ * it once.
+ */
+ fun normalized(): CuratedSchema {
+ val fields = fields.toMutableList()
+
+ for ((tag, fallback) in CuratedField.MANDATORY) {
+ val index = fields.indexOfFirst { it.tag() == tag }
+ if (index == -1) {
+ fields.add(0, fallback)
+ } else if (!fields[index].isRequired) {
+ fields[index] = fields[index].copy(isRequired = true)
+ }
+ }
+
+ return copy(fields = fields)
+ }
+
+ /**
+ * Every reason this is not a usable schema, or nothing.
+ *
+ * The NIP's rejection rules and only those: a client MUST refuse a schema
+ * missing its identifier, title, name, description or visibility, one whose
+ * visibility is not one of the three words, one with no fields, and one
+ * whose picture is not https, whose domain is not a hostname, or whose relay
+ * is not a relay. The length caps are the reference module's, so a schema
+ * this app signs is one that site accepts.
+ *
+ * Checked on the schema as written, before [normalized] -- "no fields" is
+ * about what the publisher said, not about what a reader would put back.
+ */
+ fun problems(): List = buildList {
+ fun text(value: String, cap: Int, missing: CuratedSchemaProblem, tooLong: CuratedSchemaProblem) {
+ if (value.isBlank()) add(missing) else if (value.length > cap) add(tooLong)
+ }
+
+ text(identifier, CuratedSchemaEvent.MAX_IDENTIFIER, CuratedSchemaProblem.IdentifierMissing, CuratedSchemaProblem.IdentifierTooLong)
+ text(title, CuratedSchemaEvent.MAX_TITLE, CuratedSchemaProblem.TitleMissing, CuratedSchemaProblem.TitleTooLong)
+ text(name, CuratedSchemaEvent.MAX_NAME, CuratedSchemaProblem.NameMissing, CuratedSchemaProblem.NameTooLong)
+ text(description, CuratedSchemaEvent.MAX_DESCRIPTION, CuratedSchemaProblem.DescriptionMissing, CuratedSchemaProblem.DescriptionTooLong)
+
+ if (visibility == null) add(CuratedSchemaProblem.VisibilityMissing)
+
+ picture?.let {
+ if (!CuratedSchemaEvent.isHttpsUrl(it) || it.length > CuratedSchemaEvent.MAX_URL) {
+ add(CuratedSchemaProblem.PictureNotHttps)
+ }
+ }
+
+ domain?.let {
+ if (!CuratedSchemaEvent.isDomain(it)) add(CuratedSchemaProblem.DomainInvalid)
+ }
+
+ if (fields.isEmpty()) add(CuratedSchemaProblem.NoFields)
+
+ if (relays.any { !CuratedSchemaEvent.isRelayUrl(it) }) add(CuratedSchemaProblem.RelayInvalid)
+ }
+
+ fun isUsable(): Boolean = problems().isEmpty()
+}
+
+/** A reason a [CuratedSchema] cannot be published or believed. See [CuratedSchema.problems]. */
+enum class CuratedSchemaProblem {
+ IdentifierMissing,
+ IdentifierTooLong,
+ TitleMissing,
+ TitleTooLong,
+ NameMissing,
+ NameTooLong,
+ DescriptionMissing,
+ DescriptionTooLong,
+ VisibilityMissing,
+ PictureNotHttps,
+ DomainInvalid,
+ NoFields,
+ RelayInvalid,
+}
+
+/**
+ * Who may suggest to a list, and who it is meant for.
+ *
+ * Suggesting only. Curation is never delegated: a public list still has exactly
+ * one curator, and the `p` tags on a closed list name extra *suggesters*.
+ *
+ * Relays are open, so [Private] is a convention clients follow rather than an
+ * encryption anybody enforces. Nothing secret belongs in a private list.
+ */
+enum class CuratedVisibility(val value: String) {
+ /** Anyone may suggest; anyone may read. */
+ Public("public"),
+
+ /** The curator and the `p` tags may suggest; anyone may read. */
+ Closed("closed"),
+
+ /** The curator and the `p` tags may suggest; clients should show it only to them. */
+ Private("private"),
+ ;
+
+ companion object {
+ /** Case-insensitive, trimmed, and null for anything but the three words. */
+ fun parse(value: String): CuratedVisibility? =
+ value.trim().lowercase().let { word -> entries.firstOrNull { it.value == word } }
+ }
+}
+
+/**
+ * What kind of value a field takes. One validation rule each, in the NIP's
+ * table; a form picks its input from this.
+ *
+ * [isNumeric] decides what `max` means: a maximum *value* for these, a maximum
+ * *length* for everything else. The double duty is safe because a field is one
+ * or the other, never both.
+ */
+enum class CuratedFieldType(val value: String, val isNumeric: Boolean = false) {
+ /** Single-line string, length-capped. */
+ Text("text"),
+
+ /** Multi-line string. */
+ LongText("longtext"),
+
+ /** One word, no whitespace -- `imdb:tt2821314`. */
+ Token("token"),
+
+ /** An http(s) URL; `https` in the config narrows it. */
+ Url("url"),
+
+ /** A URL that must be https, since browsers block insecure images. */
+ Image("image"),
+
+ /** One of the config's `options`. */
+ Enum("enum"),
+
+ /** An integer year, bounded by `min` and `max`. */
+ Year("year", isNumeric = true),
+
+ /** A positive integer number of seconds, bounded by `min` and `max`. */
+ Duration("duration", isNumeric = true),
+
+ /** An integer, bounded by `min` and `max`. */
+ Number("number", isNumeric = true),
+ ;
+
+ companion object {
+ fun parse(value: String): CuratedFieldType? = entries.firstOrNull { it.value == value }
+ }
+}
+
+/**
+ * Everything about a field beyond its name, type, optionality, placeholder and
+ * label -- the JSON object in the seventh position of a `field` tag.
+ *
+ * Null throughout means "not said", and a key that is not said is not written,
+ * so a config with nothing in it is `{}`. [others] carries keys this app does
+ * not know, so editing a field another client wrote does not silently strip
+ * what that client meant by it.
+ */
+data class CuratedFieldConfig(
+ /** The tag the field writes to. Defaults to the field's name; [CONTENT_TAG] means the body. */
+ val tag: String? = null,
+ /** A marker written as the tag's third element -- `["r", url, "watch"]`. */
+ val marker: String? = null,
+ /** Maximum characters for text-ish types, maximum value for numeric ones. */
+ val max: Long? = null,
+ /** Minimum value. Numeric types only. */
+ val min: Long? = null,
+ /** The allowed values of an enum. */
+ val options: List? = null,
+ /** Require https on a url. */
+ val https: Boolean? = null,
+ /** The tag may appear more than once. */
+ val repeat: Boolean? = null,
+ /** A regular expression the trimmed value must match end to end. */
+ val pattern: String? = null,
+ /** The client fills the value in and must not prompt for it. */
+ val derived: Boolean? = null,
+ /** Helper text under the input. */
+ val hint: String? = null,
+ /** Keys this app does not know, kept as they came. */
+ val others: Map = emptyMap(),
+) {
+ /** The blob as it goes into the tag. Known keys first, in the NIP's order, then [others]. */
+ fun toJson(): String = buildJsonObject {
+ tag?.let { put(KEY_TAG, it) }
+ marker?.let { put(KEY_MARKER, it) }
+ max?.let { put(KEY_MAX, it) }
+ min?.let { put(KEY_MIN, it) }
+ options?.let { put(KEY_OPTIONS, JsonArray(it.map(::JsonPrimitive))) }
+ https?.let { put(KEY_HTTPS, it) }
+ repeat?.let { put(KEY_REPEAT, it) }
+ pattern?.let { put(KEY_PATTERN, it) }
+ derived?.let { put(KEY_DERIVED, it) }
+ hint?.let { put(KEY_HINT, it) }
+ others.forEach { (key, value) -> put(key, value) }
+ }.toString()
+
+ companion object {
+ /** The `tag` value meaning the event's content rather than a tag. */
+ const val CONTENT_TAG = "content"
+
+ private const val KEY_TAG = "tag"
+ private const val KEY_MARKER = "marker"
+ private const val KEY_MAX = "max"
+ private const val KEY_MIN = "min"
+ private const val KEY_OPTIONS = "options"
+ private const val KEY_HTTPS = "https"
+ private const val KEY_REPEAT = "repeat"
+ private const val KEY_PATTERN = "pattern"
+ private const val KEY_DERIVED = "derived"
+ private const val KEY_HINT = "hint"
+
+ private val KNOWN_KEYS = setOf(
+ KEY_TAG, KEY_MARKER, KEY_MAX, KEY_MIN, KEY_OPTIONS,
+ KEY_HTTPS, KEY_REPEAT, KEY_PATTERN, KEY_DERIVED, KEY_HINT,
+ )
+
+ /**
+ * The blob read back, leniently.
+ *
+ * A malformed blob costs the field its constraints and not its life: the
+ * NIP says a bad config must not invalidate the field, so anything that
+ * is not a JSON object reads as no config at all, and a known key of the
+ * wrong type is dropped rather than kept as something it is not.
+ */
+ fun fromJson(json: String?): CuratedFieldConfig {
+ if (json.isNullOrBlank()) return CuratedFieldConfig()
+
+ val obj = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() as? JsonObject
+ ?: return CuratedFieldConfig()
+
+ fun string(key: String): String? =
+ (obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
+
+ fun long(key: String): Long? =
+ (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull
+
+ fun boolean(key: String): Boolean? =
+ (obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
+
+ return CuratedFieldConfig(
+ tag = string(KEY_TAG),
+ marker = string(KEY_MARKER),
+ max = long(KEY_MAX),
+ min = long(KEY_MIN),
+ options = (obj[KEY_OPTIONS] as? JsonArray)
+ ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.contentOrNull },
+ https = boolean(KEY_HTTPS),
+ repeat = boolean(KEY_REPEAT),
+ pattern = string(KEY_PATTERN),
+ derived = boolean(KEY_DERIVED),
+ hint = string(KEY_HINT),
+ others = obj.filterKeys { it !in KNOWN_KEYS },
+ )
+ }
+ }
+}
+
+/**
+ * One field an entry may -- or must -- carry.
+ *
+ * ```
+ * ["field", name, type, "required" | "optional", placeholder, label, config]
+ * ```
+ *
+ * The four things a form needs are positional and everything else is the JSON
+ * blob in the last position, which is [CuratedFieldConfig].
+ */
+data class CuratedField(
+ /** Stable name; the key a form collects the value under. */
+ val name: String,
+ val type: CuratedFieldType,
+ val isRequired: Boolean,
+ /** An example value shown in an empty input. May be empty. */
+ val placeholder: String = "",
+ /** The human label for the input. Empty means [name]. */
+ val label: String = "",
+ val config: CuratedFieldConfig = CuratedFieldConfig(),
+) {
+ /** The tag this field writes to; [CuratedFieldConfig.CONTENT_TAG] means the body. */
+ fun tag(): String = config.tag ?: name
+
+ fun labelOrName(): String = label.ifBlank { name }
+
+ fun toTagArray(): Array = arrayOf(
+ TAG_NAME,
+ name,
+ type.value,
+ if (isRequired) REQUIRED else OPTIONAL,
+ placeholder,
+ label,
+ config.toJson(),
+ )
+
+ companion object {
+ const val TAG_NAME = "field"
+
+ private const val REQUIRED = "required"
+ private const val OPTIONAL = "optional"
+
+ /**
+ * The tag read back, or null for one that is not a field.
+ *
+ * Null for a blank name or an unknown type, which are the two things a
+ * form cannot do anything with. Everything after them is optional: a
+ * missing placeholder is empty, a missing label falls back to the name,
+ * and a missing or malformed config is no config -- see
+ * [CuratedFieldConfig.fromJson].
+ */
+ fun parse(tag: Array): CuratedField? {
+ if (tag.size < 3 || tag[0] != TAG_NAME) return null
+
+ val name = tag[1].trim()
+ if (name.isEmpty()) return null
+
+ val type = CuratedFieldType.parse(tag[2]) ?: return null
+
+ return CuratedField(
+ name = name,
+ type = type,
+ isRequired = tag.getOrNull(3) == REQUIRED,
+ placeholder = tag.getOrNull(4) ?: "",
+ label = tag.getOrNull(5) ?: "",
+ config = CuratedFieldConfig.fromJson(tag.getOrNull(6)),
+ )
+ }
+
+ /** The `title` field a schema gets when it left its own out. */
+ val TITLE = CuratedField(
+ name = "title",
+ type = CuratedFieldType.Text,
+ isRequired = true,
+ label = "Title",
+ config = CuratedFieldConfig(max = 200),
+ )
+
+ /**
+ * The `d` field a schema gets when it left its own out. Derived, so a
+ * form never asks for it: the client works an identifier out of the
+ * entry itself.
+ */
+ val IDENTIFIER = CuratedField(
+ name = "identifier",
+ type = CuratedFieldType.Token,
+ isRequired = true,
+ label = "Identifier",
+ config = CuratedFieldConfig(tag = "d", max = 80, derived = true),
+ )
+
+ /**
+ * The two fields every list has, keyed by the tag each writes to and in
+ * the order [CuratedSchema.normalized] puts them back -- title first, so
+ * that `d` ends up in front of it.
+ */
+ val MANDATORY: List> = listOf(
+ "title" to TITLE,
+ "d" to IDENTIFIER,
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt
new file mode 100644
index 00000000..37570a6e
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt
@@ -0,0 +1,206 @@
+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
+
+/**
+ * Kind 31889: a curated schema event, and the two kinds it governs.
+ *
+ * ```
+ * 31889 curated schema the definition of a list, by its curator
+ * 31888 curated suggestion an entry anyone proposes, in reply to the schema
+ * 31890 curated canonical a suggestion the curator republished as the list's own
+ * ```
+ *
+ * All three are addressable, so `kind:pubkey:d` names one entry and republishing
+ * with the same `d` replaces it. That is what makes a schema revisable: editing
+ * one signs a new event under the same coordinate, and every suggestion that
+ * replied to the coordinate -- rather than to an event id -- stays attached.
+ *
+ * This object is the wire shape: tags in, tags out. [CuratedSchema] is what the
+ * tags mean, and `GroupCuratedSchema` is a group's own signed one.
+ */
+object CuratedSchemaEvent {
+ const val KIND: Kind = 31889
+
+ /** The kind a schema's suggestions are published under -- its `k` tag. */
+ const val SUGGESTION_KIND: Kind = 31888
+
+ /** The kind the curator republishes an accepted suggestion as. */
+ const val CANONICAL_KIND: Kind = 31890
+
+ /** Length caps on the identity tags, the reference module's. */
+ const val MAX_IDENTIFIER = 100
+ const val MAX_TITLE = 200
+ const val MAX_NAME = 100
+ const val MAX_DESCRIPTION = 500
+ const val MAX_URL = 500
+ const val MAX_DOMAIN = 253
+
+ private const val TAG_IDENTIFIER = "d"
+ private const val TAG_TITLE = "title"
+ private const val TAG_NAME = "name"
+ private const val TAG_DESCRIPTION = "description"
+ private const val TAG_KIND = "k"
+ private const val TAG_VISIBILITY = "visibility"
+ private const val TAG_PICTURE = "picture"
+ private const val TAG_DOMAIN = "domain"
+ private const val TAG_REQUIRE_ANY = "require-any"
+ private const val TAG_SUGGESTER = "p"
+ private const val TAG_RELAY = "relay"
+
+ /** The addressable coordinate: what a suggestion's `a` root names. */
+ fun coordinate(pubkey: HexKey, identifier: String): String = "$KIND:$pubkey:$identifier"
+
+ /**
+ * The schema an event declares, or null when the event is not a usable one.
+ *
+ * Defensive in the way a relay demands. The wrong kind is null. Any of
+ * [CuratedSchema.problems] is null -- a schema that would be rejected on
+ * publish is rejected on read, and for the same reasons. A `field` tag that
+ * will not parse is dropped and the rest are kept, which is the one place
+ * the NIP asks for tolerance; a `require-any` naming fewer than two fields
+ * says nothing and goes the same way.
+ *
+ * What comes back is [CuratedSchema.normalized]: the two mandatory fields
+ * forced in, whatever the event said.
+ */
+ fun parse(event: Event): CuratedSchema? {
+ if (event.kind != KIND) return null
+
+ return read(event.tags, event.content).takeIf { it.isUsable() }?.normalized()
+ }
+
+ /**
+ * The tags as a schema, whether or not they make a usable one.
+ *
+ * Split from [parse] so that a reader wanting the reasons -- the editor,
+ * a test -- can ask [CuratedSchema.problems] of the result. The identity
+ * values are trimmed, and the description falls back to the content the way
+ * generic clients read it: the tag is authoritative and the content is the
+ * mirror.
+ */
+ fun read(tags: Array>, content: String): CuratedSchema {
+ fun first(name: String): String? =
+ tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1)
+
+ fun all(name: String): List =
+ tags.filter { it.size > 1 && it[0] == name }.map { it[1] }
+
+ return CuratedSchema(
+ identifier = first(TAG_IDENTIFIER)?.trim() ?: "",
+ title = first(TAG_TITLE)?.trim() ?: "",
+ name = first(TAG_NAME)?.trim() ?: "",
+ description = (first(TAG_DESCRIPTION)?.trim()?.ifEmpty { null } ?: content).trim(),
+ // Null for an absent or unrecognised word, and null stays null: see
+ // the field's own note on why it is never defaulted.
+ visibility = first(TAG_VISIBILITY)?.let(CuratedVisibility::parse),
+ picture = first(TAG_PICTURE)?.trim()?.ifEmpty { null },
+ domain = first(TAG_DOMAIN)?.trim()?.ifEmpty { null }?.let(::normalizeDomain),
+ suggestionKind = first(TAG_KIND)?.trim()?.toIntOrNull() ?: SUGGESTION_KIND,
+ fields = tags.mapNotNull(CuratedField::parse),
+ requireAny = tags
+ .filter { it.isNotEmpty() && it[0] == TAG_REQUIRE_ANY }
+ .map { group -> group.drop(1).map { it.trim() }.filter { it.isNotEmpty() } }
+ .filter { it.size > 1 },
+ // Only what is a pubkey. A `p` tag holding anything else names nobody,
+ // and keeping it would have the editor render it as an npub it is not.
+ suggesters = all(TAG_SUGGESTER).map { it.trim().lowercase() }.filter(::isPubkey).distinct(),
+ relays = all(TAG_RELAY).map { it.trim() }.distinct(),
+ )
+ }
+
+ /**
+ * The unsigned event that publishes [schema], for the group to sign.
+ *
+ * Built from the schema alone rather than from the group's last event, the
+ * way a relay list is and for the same reason: a schema is one document
+ * whose whole point in being re-signed is to replace the last one, and
+ * carrying an old tag forward would make removing a field impossible.
+ *
+ * The content mirrors the description so that a client reading content
+ * rather than tags still shows something; the tag is the authoritative one.
+ */
+ fun template(
+ schema: CuratedSchema,
+ createdAt: Long = Clock.System.now().epochSeconds,
+ ): EventTemplate {
+ val normalized = schema.normalized()
+
+ val tags = buildList {
+ add(arrayOf(TAG_IDENTIFIER, normalized.identifier))
+ add(arrayOf(TAG_TITLE, normalized.title))
+ add(arrayOf(TAG_NAME, normalized.name))
+ add(arrayOf(TAG_DESCRIPTION, normalized.description))
+ add(arrayOf(TAG_KIND, normalized.suggestionKind.toString()))
+ // Absent rather than invented when the schema has none. Every reader,
+ // this one included, then refuses the event -- which is the right
+ // outcome for a schema that does not say who may suggest to it, and
+ // one the editor never produces.
+ normalized.visibility?.let { add(arrayOf(TAG_VISIBILITY, it.value)) }
+
+ normalized.picture?.let { add(arrayOf(TAG_PICTURE, it)) }
+ normalized.domain?.let { add(arrayOf(TAG_DOMAIN, normalizeDomain(it))) }
+
+ normalized.fields.forEach { add(it.toTagArray()) }
+ normalized.requireAny.forEach { group -> add(arrayOf(TAG_REQUIRE_ANY, *group.toTypedArray())) }
+ normalized.suggesters.forEach { add(arrayOf(TAG_SUGGESTER, it)) }
+ normalized.relays.forEach { add(arrayOf(TAG_RELAY, it)) }
+ }
+
+ return EventTemplate(
+ createdAt = createdAt,
+ kind = KIND,
+ tags = tags.toTypedArray(),
+ content = normalized.description,
+ )
+ }
+
+ /**
+ * A hostname and nothing else: at least two labels, each of letters, digits
+ * and inner hyphens, no scheme, port or path, 253 characters at most. What
+ * [normalizeDomain] leaves of a pasted URL is checked against this, so
+ * `https://Bitcoin.MOV/` is accepted and stored as `bitcoin.mov`.
+ */
+ private val DOMAIN = Regex(
+ "^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$"
+ )
+
+ /** Trim the things people paste around a bare hostname. */
+ fun normalizeDomain(value: String): String = value
+ .trim()
+ .lowercase()
+ .replace(Regex("^[a-z][a-z0-9+.-]*://"), "")
+ .removePrefix("@")
+ .replace(Regex("/.*$"), "")
+ .replace(Regex(":\\d+$"), "")
+ .removeSuffix(".")
+
+ /** 64 lowercase hex characters: an x-only pubkey as nostr writes one. */
+ fun isPubkey(value: String): Boolean =
+ value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
+
+ fun isDomain(value: String): Boolean =
+ normalizeDomain(value).let { it.length <= MAX_DOMAIN && DOMAIN.matches(it) }
+
+ /**
+ * An https URL with a host: the only kind of picture a schema may carry,
+ * because it gets rendered and browsers block the rest on a secure page.
+ */
+ fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value.trim())
+
+ private val HTTPS_URL = Regex("^https://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
+
+ /**
+ * A `ws://` or `wss://` URL with a host, which is what the NIP accepts in a
+ * `relay` tag. The editor is stricter about what it will let a group sign --
+ * see `GroupRelaySet.relayUrlOrNull` -- but a schema read off a relay is held
+ * to the NIP's rule and not to this app's.
+ */
+ fun isRelayUrl(value: String): Boolean = RELAY_URL.matches(value.trim())
+
+ private val RELAY_URL = Regex("^wss?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
index 9b44ee7c..7ac07fa4 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt
@@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.managers.MarmotGroupCreation
import press.mantra.compose.managers.SubgroupManager
import press.mantra.compose.nostr.GroupNostrProfile
+import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupPost
import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.database.model.Participant
@@ -113,6 +114,17 @@ interface ChatRepository {
*/
suspend fun groupPosts(chatRoomId: String): List
+ /**
+ * The curated schemas this group signed -- the lists it curates -- one per
+ * identifier, most recently signed first.
+ *
+ * Kind 31889 is addressable, so an edit is a newer event under the same
+ * identifier and the newest wins per list, the way the profile does; two
+ * identifiers are two lists and both come back. Empty in a group that
+ * curates nothing, which is every group until somebody proposes one.
+ */
+ suspend fun groupCuratedSchemas(chatRoomId: String): List
+
/**
* Whether this device holds a share of the group's key, and so could take
* part in signing for it.
@@ -301,6 +313,10 @@ interface ChatRepository {
override suspend fun groupPosts(chatRoomId: String): List = emptyList()
+ override suspend fun groupCuratedSchemas(
+ chatRoomId: String
+ ): List = emptyList()
+
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun refuseSubgroup(
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 7b11f466..523fca38 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt
@@ -2,6 +2,7 @@ package press.mantra.compose.text
import com.vitorpamplona.quartz.nip01Core.core.Event
import press.mantra.compose.managers.SharedKeyDerivation
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
import press.mantra.compose.nostr.nip30303.ArtifactEvent
@@ -155,6 +156,22 @@ object ProposedEvent {
).joinToString(" · ")
)
+ // A list the group would curate. A member signing this is agreeing to
+ // take suggestions from whoever the visibility admits and to be the one
+ // key that accepts them, so the summary is the list's name, who may
+ // suggest, and how much an entry asks for -- the content is only the
+ // description, which the detail screen shows whole.
+ CuratedSchemaEvent.KIND -> Summary(
+ label = "Curated schema",
+ detail = CuratedSchemaEvent.read(event.tags, event.content).let { schema ->
+ listOfNotNull(
+ schema.displayName().ifBlank { null },
+ schema.visibility?.value,
+ schema.fields.size.let { "$it ${if (it == 1) "field" else "fields"}" }
+ ).joinToString(" · ")
+ }
+ )
+
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
}
}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
index 96894ba6..33dabc79 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Badge
+import androidx.compose.material.icons.filled.Ballot
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Campaign
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Draw
@@ -74,6 +77,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
+import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
@@ -100,6 +104,10 @@ import mantra.composeapp.generated.resources.relays
import mantra.composeapp.generated.resources.posts
import mantra.composeapp.generated.resources.add_post
import mantra.composeapp.generated.resources.a_reply
+import mantra.composeapp.generated.resources.curated_schemas
+import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet
+import mantra.composeapp.generated.resources.add_schema
+import mantra.composeapp.generated.resources.fields_colon
import mantra.composeapp.generated.resources.posted_on
import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet
import mantra.composeapp.generated.resources.edit_relays
@@ -124,6 +132,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.managers.SubgroupManager
+import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupNostrProfile
import press.mantra.compose.nostr.GroupPost
import press.mantra.compose.nostr.GroupRelayList
@@ -521,6 +530,90 @@ fun ChatRoomDetailScreen(
}
}
+ // Under the posts, because a schema is the other
+ // thing a group publishes for strangers to answer:
+ // a post is the group speaking, a schema is the
+ // group asking -- for entries to a list it will
+ // then curate, by quorum, under the same key. It
+ // closes the identity block because everything in
+ // that block is signed by that key and read by
+ // people who were never in the room.
+ item {
+ Text(
+ text = stringResource(Res.string.curated_schemas),
+ style = MaterialTheme.typography.labelMedium
+ )
+ }
+
+ if (chatRoomDetailUIState.groupCuratedSchemas.isEmpty()) {
+ item {
+ Text(
+ stringResource(
+ Res.string.this_group_has_not_published_a_curated_schema_yet
+ )
+ )
+ }
+ } else {
+ items(
+ items = chatRoomDetailUIState.groupCuratedSchemas,
+ key = { curated -> curated.signedEvent.id }
+ ) { curated ->
+ GroupCuratedSchemaCard(
+ curatedSchema = curated,
+ // Into the editor for a member who
+ // could propose a revision, and a plain
+ // card for one who could not: the same
+ // gate as every button in this block,
+ // applied to the card because each
+ // schema is edited on its own.
+ onEdit = if (chatRoomDetailUIState.canEditNostrProfile) {
+ {
+ onNavigateToRoute.invoke(
+ EditGroupCuratedSchemaRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ identifier = curated.identifier
+ )
+ )
+ }
+ } else {
+ null
+ }
+ )
+ }
+ }
+
+ if (chatRoomDetailUIState.canEditNostrProfile) {
+ item {
+ TextButton(
+ onClick = {
+ onNavigateToRoute.invoke(
+ EditGroupCuratedSchemaRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ identifier = null
+ )
+ )
+ }
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.PlaylistAdd,
+ contentDescription = Decorative
+ )
+
+ Spacer(
+ modifier = Modifier.width(
+ MaterialTheme.spacing.space125
+ )
+ )
+
+ Text(stringResource(Res.string.add_schema))
+ }
+ }
+ }
+
item {
HorizontalDivider()
}
@@ -1469,6 +1562,92 @@ private fun GroupPostCard(
}
}
+/**
+ * One list the group curates, as the schema it signed for it.
+ *
+ * Named by the list -- the domain where it claims one, else the name -- because
+ * that is what a suggester sees the list called; the title under it is the label
+ * the event carries. The fields are listed by label rather than counted, since
+ * what a reader wants to know about a list is what an entry in it asks for, and
+ * "13 fields" answers a different question.
+ *
+ * The visibility is on the last line beside the date, because who may suggest is
+ * the one fact on the card a member might act on -- a closed list is one they
+ * would have to be named on.
+ *
+ * [onEdit] null draws a card that cannot be opened. It is the section's gate
+ * applied per card: a schema is edited on its own, so the way in has to be on it.
+ */
+@Composable
+private fun GroupCuratedSchemaCard(
+ curatedSchema: GroupCuratedSchema,
+ onEdit: (() -> Unit)?
+) {
+ val content: @Composable () -> Unit = {
+ ListItem(
+ leadingContent = {
+ Icon(Icons.Default.Ballot, contentDescription = Decorative)
+ },
+ trailingContent = onEdit?.let {
+ { Icon(Icons.Default.Edit, contentDescription = "Edit schema") }
+ },
+ headlineContent = {
+ Text(
+ text = curatedSchema.schema.displayName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ Text(
+ text = curatedSchema.schema.title,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Text(
+ text = curatedSchema.schema.description,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Text(
+ text = stringResource(
+ Res.string.fields_colon,
+ curatedSchema.schema.fields.joinToString { it.labelOrName() }
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ Text(
+ text = listOfNotNull(
+ curatedSchema.schema.visibility?.let { stringResource(it.label()) },
+ stringResource(
+ Res.string.signed_by_the_group_on,
+ curatedSchema.signedAt.toFormattedTimeAndDateString()
+ )
+ ).joinToString(" · "),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ )
+ }
+
+ if (onEdit != null) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() }
+ } else {
+ Card(modifier = Modifier.fillMaxWidth()) { content() }
+ }
+}
+
/**
* One subgroup, as the parent's record and this device's rooms together have it.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt
new file mode 100644
index 00000000..b3cba3f7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt
@@ -0,0 +1,1521 @@
+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.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.TextFieldState
+import androidx.compose.foundation.text.input.clearText
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Label
+import androidx.compose.material.icons.automirrored.filled.Notes
+import androidx.compose.material.icons.automirrored.filled.Rule
+import androidx.compose.material.icons.automirrored.filled.ShortText
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Ballot
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Checklist
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Dns
+import androidx.compose.material.icons.filled.Draw
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material.icons.filled.Language
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.material.icons.filled.Numbers
+import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material.icons.filled.Sell
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material.icons.filled.Title
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.BottomAppBarDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.disabled
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.lifecycle.viewmodel.compose.viewModel
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.extensions.hexToNpubHrp
+import press.mantra.compose.nostr.curated.CuratedField
+import press.mantra.compose.nostr.curated.CuratedFieldType
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
+import press.mantra.compose.nostr.curated.CuratedSchemaProblem
+import press.mantra.compose.nostr.curated.CuratedVisibility
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+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.dialogs.ModalBottomSheet
+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.EditGroupCuratedSchemaViewModel
+import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddRelayFailure
+import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.AddSuggesterFailure
+import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldEdit
+import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel.FieldProblem
+import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import mantra.composeapp.generated.resources.Res
+import mantra.composeapp.generated.resources.a_description_is_required
+import mantra.composeapp.generated.resources.a_domain_replaces_the_name_wherever_the_list_is_shown
+import mantra.composeapp.generated.resources.a_field_name_is_one_word
+import mantra.composeapp.generated.resources.a_field_named_that_already_exists
+import mantra.composeapp.generated.resources.a_field_needs_a_name
+import mantra.composeapp.generated.resources.a_name_is_required
+import mantra.composeapp.generated.resources.a_regular_expression_the_whole_value_must_match
+import mantra.composeapp.generated.resources.a_schema_needs_at_least_one_field
+import mantra.composeapp.generated.resources.a_title_is_required
+import mantra.composeapp.generated.resources.add_field
+import mantra.composeapp.generated.resources.add_rule
+import mantra.composeapp.generated.resources.add_schema
+import mantra.composeapp.generated.resources.an_entry_must_have_this_field
+import mantra.composeapp.generated.resources.an_identifier_is_required
+import mantra.composeapp.generated.resources.at_least_one_of
+import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_schema
+import mantra.composeapp.generated.resources.defaults_to_the_field_name_content_means_the_body
+import mantra.composeapp.generated.resources.derived
+import mantra.composeapp.generated.resources.description
+import mantra.composeapp.generated.resources.domain
+import mantra.composeapp.generated.resources.done
+import mantra.composeapp.generated.resources.edit_field
+import mantra.composeapp.generated.resources.edit_schema
+import mantra.composeapp.generated.resources.eg_1900
+import mantra.composeapp.generated.resources.eg_200
+import mantra.composeapp.generated.resources.eg_2100
+import mantra.composeapp.generated.resources.eg_a_za_z_2_8
+import mantra.composeapp.generated.resources.eg_bitcoin_mov
+import mantra.composeapp.generated.resources.eg_movie_documentary_short
+import mantra.composeapp.generated.resources.eg_bitcoin_mov_suggestion
+import mantra.composeapp.generated.resources.eg_example_com
+import mantra.composeapp.generated.resources.eg_fields_for_a_bitcoin_mov_entry
+import mantra.composeapp.generated.resources.eg_https_example_com_group_png
+import mantra.composeapp.generated.resources.eg_https_youtube_com_watch
+import mantra.composeapp.generated.resources.eg_watch
+import mantra.composeapp.generated.resources.eg_watch_reference_url
+import mantra.composeapp.generated.resources.eg_watchurl
+import mantra.composeapp.generated.resources.eg_wss_relay_example_com
+import mantra.composeapp.generated.resources.field_name
+import mantra.composeapp.generated.resources.field_type_duration
+import mantra.composeapp.generated.resources.field_type_enum
+import mantra.composeapp.generated.resources.field_type_image
+import mantra.composeapp.generated.resources.field_type_longtext
+import mantra.composeapp.generated.resources.field_type_number
+import mantra.composeapp.generated.resources.field_type_text
+import mantra.composeapp.generated.resources.field_type_token
+import mantra.composeapp.generated.resources.field_type_url
+import mantra.composeapp.generated.resources.field_type_year
+import mantra.composeapp.generated.resources.fields
+import mantra.composeapp.generated.resources.filled_in_by_the_app_never_asked_for
+import mantra.composeapp.generated.resources.helper_text_under_the_input
+import mantra.composeapp.generated.resources.hint
+import mantra.composeapp.generated.resources.https_only
+import mantra.composeapp.generated.resources.identifier
+import mantra.composeapp.generated.resources.label
+import mantra.composeapp.generated.resources.marker
+import mantra.composeapp.generated.resources.maximum_characters
+import mantra.composeapp.generated.resources.maximum_value
+import mantra.composeapp.generated.resources.may_appear_more_than_once
+import mantra.composeapp.generated.resources.minimum_value
+import mantra.composeapp.generated.resources.name
+import mantra.composeapp.generated.resources.new_field
+import mantra.composeapp.generated.resources.no_relays_named_so_a_client_will_pick_its_own
+import mantra.composeapp.generated.resources.nothing_has_changed_to_propose
+import mantra.composeapp.generated.resources.npub_or_hex_pubkey
+import mantra.composeapp.generated.resources.one_of_the_relays_is_not_a_relay_address
+import mantra.composeapp.generated.resources.one_per_line_or_separated_by_commas
+import mantra.composeapp.generated.resources.optional
+import mantra.composeapp.generated.resources.options
+import mantra.composeapp.generated.resources.pattern
+import mantra.composeapp.generated.resources.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have
+import mantra.composeapp.generated.resources.picture_url
+import mantra.composeapp.generated.resources.placeholder
+import mantra.composeapp.generated.resources.propose_schema
+import mantra.composeapp.generated.resources.pubkeys_besides_the_group_that_may_suggest_to_this_list
+import mantra.composeapp.generated.resources.relay_url
+import mantra.composeapp.generated.resources.relays
+import mantra.composeapp.generated.resources.remove_field
+import mantra.composeapp.generated.resources.required
+import mantra.composeapp.generated.resources.say_who_may_suggest_to_this_list
+import mantra.composeapp.generated.resources.suggesters
+import mantra.composeapp.generated.resources.that_is_not_a_domain_name
+import mantra.composeapp.generated.resources.that_is_not_a_pubkey
+import mantra.composeapp.generated.resources.that_is_not_a_relay_address
+import mantra.composeapp.generated.resources.that_pubkey_is_already_a_suggester
+import mantra.composeapp.generated.resources.that_relay_is_already_in_this_list
+import mantra.composeapp.generated.resources.the_description_must_be_at_most_n_characters
+import mantra.composeapp.generated.resources.the_group_signs_a_schema_so_it_takes_a_quorum
+import mantra.composeapp.generated.resources.the_identifier_must_be_at_most_n_characters
+import mantra.composeapp.generated.resources.the_identifier_names_this_list_in_every_reply
+import mantra.composeapp.generated.resources.the_name_must_be_at_most_n_characters
+import mantra.composeapp.generated.resources.the_picture_must_be_an_https_url
+import mantra.composeapp.generated.resources.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart
+import mantra.composeapp.generated.resources.the_title_must_be_at_most_n_characters
+import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_schema
+import mantra.composeapp.generated.resources.title
+import mantra.composeapp.generated.resources.type
+import mantra.composeapp.generated.resources.visibility
+import mantra.composeapp.generated.resources.visibility_closed
+import mantra.composeapp.generated.resources.visibility_closed_purpose
+import mantra.composeapp.generated.resources.visibility_private
+import mantra.composeapp.generated.resources.visibility_private_purpose
+import mantra.composeapp.generated.resources.visibility_public
+import mantra.composeapp.generated.resources.visibility_public_purpose
+import mantra.composeapp.generated.resources.what_an_entry_in_this_list_may_contain
+import mantra.composeapp.generated.resources.where_suggestions_to_this_list_are_published
+import mantra.composeapp.generated.resources.writes_to_tag
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * The form for a list the group curates: what an entry may contain, who may
+ * suggest one, and where suggestions go.
+ *
+ * One screen for the whole schema, because the schema is one event and one
+ * signature. The identity -- identifier, title, name, description, visibility,
+ * picture, domain -- is text fields like every other editor's. Under it are the
+ * four things a schema has several of: fields, rules saying at least one of some
+ * fields must be present, extra suggesters on a closed list, and relays. Each
+ * is a list with a row per entry and a way to add one, and a field is edited on
+ * a sheet of its own, since a field has a dozen settings and thirteen of them
+ * inline would be a screen nobody could find anything on.
+ *
+ * **The button proposes rather than saves.** The schema goes out as one event
+ * for the room's quorum to put its key to, and the screen hands over to the
+ * signing session it opened. Nothing about the list has changed by the time this
+ * screen closes -- and until a quorum signs, suggesters have nothing to reply to.
+ *
+ * **An edit keeps the identifier.** The identifier is the coordinate every
+ * suggestion to the list replies to, so changing it would not edit the list but
+ * start a second one and orphan the first's queue. The field is read-only on an
+ * edit and says why; a new list is "Add schema" on the group's screen.
+ */
+@OptIn(
+ ExperimentalMaterial3ExpressiveApi::class,
+ ExperimentalMaterial3Api::class,
+ ExperimentalLayoutApi::class
+)
+@Composable
+fun EditGroupCuratedSchemaScreen(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ identifier: String?,
+ initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState =
+ EditGroupCuratedSchemaUIState.Loading,
+ chatRepository: ChatRepository,
+ frostSigningRepository: FrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
+) {
+ val editGroupCuratedSchemaViewModel: EditGroupCuratedSchemaViewModel = viewModel(
+ factory = EditGroupCuratedSchemaViewModel.factory(
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ identifier = identifier,
+ initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState,
+ activeUserPublicKey = activeUserPublicKey,
+ chatRepository = chatRepository,
+ frostSigningRepository = frostSigningRepository
+ )
+ )
+
+ // 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_schema)
+ val nothingChanged = stringResource(Res.string.nothing_has_changed_to_propose)
+
+ ScreenStateTransition(editGroupCuratedSchemaViewModel.editGroupCuratedSchemaUIState) { uiState ->
+ when (val editGroupCuratedSchemaUIState = uiState) {
+ is EditGroupCuratedSchemaUIState.Error -> {
+ // Nothing to retry: the room and the identifier came from a
+ // navigation argument, and reading them again with the same ones
+ // fails the same way.
+ ErrorState(message = editGroupCuratedSchemaUIState.message, onRetry = null)
+ }
+
+ is EditGroupCuratedSchemaUIState.Loaded -> {
+ val existing = editGroupCuratedSchemaUIState.existing
+ val schema = existing?.schema
+ val canSign = editGroupCuratedSchemaUIState.canSign
+
+ // Seeded from the schema the group already signed, so the form is
+ // an edit of it. `rememberTextFieldState` saves what is typed, which
+ // is what survives a rotation with the edits intact.
+ val identifierFieldState = rememberTextFieldState(schema?.identifier ?: "")
+ val titleFieldState = rememberTextFieldState(schema?.title ?: "")
+ val nameFieldState = rememberTextFieldState(schema?.name ?: "")
+ val descriptionFieldState = rememberTextFieldState(schema?.description ?: "")
+ val pictureFieldState = rememberTextFieldState(schema?.picture ?: "")
+ val domainFieldState = rememberTextFieldState(schema?.domain ?: "")
+ val relayFieldState = rememberTextFieldState()
+ val suggesterFieldState = rememberTextFieldState()
+
+ // The lists 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(editGroupCuratedSchemaUIState) {
+ editGroupCuratedSchemaViewModel.seedWorkingCopy(editGroupCuratedSchemaUIState)
+ }
+
+ // A refusal is about what is in the field, so it goes the moment
+ // the field changes rather than sitting under a value that has
+ // since been corrected.
+ LaunchedEffect(relayFieldState) {
+ snapshotFlow { relayFieldState.text.toString() }
+ .collect { editGroupCuratedSchemaViewModel.clearAddRelayFailure() }
+ }
+ LaunchedEffect(suggesterFieldState) {
+ snapshotFlow { suggesterFieldState.text.toString() }
+ .collect { editGroupCuratedSchemaViewModel.clearAddSuggesterFailure() }
+ }
+
+ // 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 = {
+ editGroupCuratedSchemaUIState.localChatRoom.RenderChatRoomTitleText()
+ }
+ )
+ },
+ 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
+
+ editGroupCuratedSchemaViewModel.proposeSchema(
+ localChatRoom =
+ editGroupCuratedSchemaUIState.localChatRoom,
+ existing = existing,
+ identifier = identifierFieldState.text.toString(),
+ title = titleFieldState.text.toString(),
+ name = nameFieldState.text.toString(),
+ description = descriptionFieldState.text.toString(),
+ picture = pictureFieldState.text.toString(),
+ domain = domainFieldState.text.toString(),
+ onSuccess = { sessionId ->
+ // Onto the session rather than back
+ // to the group. The schema has not
+ // changed yet -- it changes when a
+ // quorum signs -- so landing on a
+ // group still showing the old one
+ // would read as a failure.
+ onNavigateToRouteAndPopUpInclusive.invoke(
+ FrostSigningRoute(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ sessionId = sessionId
+ )
+ )
+ },
+ // The reasons are drawn on the form
+ // itself, where the fields they are
+ // about are; a snackbar would name a
+ // problem and scroll away from it.
+ onInvalid = {},
+ // Both stay on the form with the edits
+ // still in it. One is a question and
+ // one is a failure, and neither is a
+ // reason to throw away the typing.
+ onNothingToPropose = { notify(nothingChanged) },
+ onFailure = { notify(couldNotPropose) }
+ )
+ }
+ ) {
+ Icon(Icons.Default.Draw, contentDescription = "Propose schema")
+ Text(stringResource(Res.string.propose_schema))
+ }
+ }
+ )
+ }
+ ) { innerPadding ->
+ // One lazy list for the whole form rather than a scrolling
+ // column, because the form has four lists in it and a lazy
+ // list cannot sit inside a scrolling column. The text fields
+ // are declared above so that scrolling one off the screen does
+ // not lose what was typed into it.
+ LazyColumn(
+ modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
+ contentPadding = PaddingValues(
+ horizontal = MaterialTheme.spacing.screenMargin,
+ vertical = MaterialTheme.spacing.itemGap
+ ),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ item {
+ Text(
+ text = stringResource(Res.string.what_an_entry_in_this_list_may_contain),
+ style = MaterialTheme.typography.titleSmall,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ item {
+ Text(
+ text = stringResource(
+ Res.string.the_group_signs_a_schema_so_it_takes_a_quorum
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+
+ if (!canSign) {
+ item {
+ Text(
+ text = stringResource(
+ Res.string.this_group_has_no_shared_key_to_sign_a_schema
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+
+ // Named rather than silent, and all of them at once: a
+ // button that does nothing for a schema it refuses is
+ // indistinguishable from a missed tap, and one reason per
+ // press would be a conversation.
+ if (editGroupCuratedSchemaViewModel.problems.isNotEmpty()) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.relatedGap
+ )
+ ) {
+ editGroupCuratedSchemaViewModel.problems.forEach { problem ->
+ Text(
+ text = problem.message(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ SchemaTextField(
+ state = identifierFieldState,
+ icon = Icons.Default.Tag,
+ iconDescription = "Identifier of the list",
+ label = stringResource(Res.string.identifier),
+ placeholder = stringResource(Res.string.eg_bitcoin_mov),
+ // Locked on an edit: see the screen's own note.
+ readOnly = existing != null,
+ supportingText = if (existing != null) {
+ stringResource(Res.string.the_identifier_names_this_list_in_every_reply)
+ } else {
+ null
+ },
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(
+ CuratedSchemaProblem.IdentifierMissing,
+ CuratedSchemaProblem.IdentifierTooLong
+ )
+ )
+ }
+
+ item {
+ SchemaTextField(
+ state = titleFieldState,
+ icon = Icons.Default.Title,
+ iconDescription = "Title of the schema",
+ label = stringResource(Res.string.title),
+ placeholder = stringResource(Res.string.eg_bitcoin_mov_suggestion),
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(
+ CuratedSchemaProblem.TitleMissing,
+ CuratedSchemaProblem.TitleTooLong
+ )
+ )
+ }
+
+ item {
+ SchemaTextField(
+ state = nameFieldState,
+ icon = Icons.AutoMirrored.Filled.Label,
+ iconDescription = "Name of the list",
+ label = stringResource(Res.string.name),
+ placeholder = stringResource(Res.string.eg_bitcoin_mov),
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(
+ CuratedSchemaProblem.NameMissing,
+ CuratedSchemaProblem.NameTooLong
+ )
+ )
+ }
+
+ item {
+ SchemaTextField(
+ state = descriptionFieldState,
+ icon = Icons.AutoMirrored.Filled.Notes,
+ iconDescription = "What the list is for",
+ label = stringResource(Res.string.description),
+ placeholder = stringResource(
+ Res.string.eg_fields_for_a_bitcoin_mov_entry
+ ),
+ // The one identity field with prose in it, and the
+ // only one a single line would truncate while it
+ // was being typed.
+ lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4),
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(
+ CuratedSchemaProblem.DescriptionMissing,
+ CuratedSchemaProblem.DescriptionTooLong
+ )
+ )
+ }
+
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.relatedGap
+ )
+ ) {
+ SectionHeading(stringResource(Res.string.visibility))
+
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ CuratedVisibility.entries.forEachIndexed { index, option ->
+ SegmentedButton(
+ selected = option == editGroupCuratedSchemaViewModel.visibility,
+ onClick = {
+ editGroupCuratedSchemaViewModel.selectVisibility(option)
+ },
+ shape = SegmentedButtonDefaults.itemShape(
+ index = index,
+ count = CuratedVisibility.entries.size
+ ),
+ label = { Text(stringResource(option.label())) }
+ )
+ }
+ }
+
+ // What the chosen word actually decides. Three
+ // words is three questions nobody can be expected
+ // to hold apart from the words alone.
+ Text(
+ text = stringResource(
+ editGroupCuratedSchemaViewModel.visibility.purpose()
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ item {
+ SchemaTextField(
+ state = pictureFieldState,
+ icon = Icons.Default.Image,
+ iconDescription = "Picture of the list",
+ label = stringResource(Res.string.picture_url),
+ placeholder = stringResource(Res.string.eg_https_example_com_group_png),
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.PictureNotHttps)
+ )
+ }
+
+ item {
+ SchemaTextField(
+ state = domainFieldState,
+ icon = Icons.Default.Language,
+ iconDescription = "Domain of the list",
+ label = stringResource(Res.string.domain),
+ placeholder = stringResource(Res.string.eg_example_com),
+ supportingText = stringResource(
+ Res.string.a_domain_replaces_the_name_wherever_the_list_is_shown
+ ),
+ isError = editGroupCuratedSchemaViewModel.problems.containsAny(CuratedSchemaProblem.DomainInvalid)
+ )
+ }
+
+ item {
+ SectionHeading(stringResource(Res.string.fields))
+ }
+
+ itemsIndexed(editGroupCuratedSchemaViewModel.fields) { index, field ->
+ SchemaFieldRow(
+ field = field,
+ isMandatory = editGroupCuratedSchemaViewModel.isMandatory(field),
+ onOpen = { editGroupCuratedSchemaViewModel.startEditingField(index) }
+ )
+ }
+
+ item {
+ TextButton(onClick = { editGroupCuratedSchemaViewModel.startAddingField() }) {
+ Icon(Icons.Default.Add, contentDescription = Decorative)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Text(stringResource(Res.string.add_field))
+ }
+ }
+
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.relatedGap
+ )
+ ) {
+ SectionHeading(stringResource(Res.string.at_least_one_of))
+
+ Text(
+ text = stringResource(
+ Res.string.pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ itemsIndexed(editGroupCuratedSchemaViewModel.requireAny) { index, group ->
+ RequireAnyRow(
+ fieldNames = editGroupCuratedSchemaViewModel.fields.map { it.name },
+ selected = group,
+ onToggle = { fieldName ->
+ editGroupCuratedSchemaViewModel.toggleRequireAny(index, fieldName)
+ },
+ onRemove = { editGroupCuratedSchemaViewModel.removeRequireAnyGroup(index) }
+ )
+ }
+
+ item {
+ TextButton(
+ onClick = { editGroupCuratedSchemaViewModel.addRequireAnyGroup() }
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Rule, contentDescription = Decorative)
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
+
+ Text(stringResource(Res.string.add_rule))
+ }
+ }
+
+ // Only where the visibility gives them something to be: on
+ // a public list anyone may suggest, and a list of people
+ // who may would say the opposite of what the word does.
+ if (editGroupCuratedSchemaViewModel.visibility != CuratedVisibility.Public) {
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.relatedGap
+ )
+ ) {
+ SectionHeading(stringResource(Res.string.suggesters))
+
+ Text(
+ text = stringResource(
+ Res.string.pubkeys_besides_the_group_that_may_suggest_to_this_list
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ items(editGroupCuratedSchemaViewModel.suggesters) { pubkey ->
+ RemovableRow(
+ // The npub, because that is the form a member
+ // pasted it in and the form they would compare
+ // it against; whole and monospaced, so it can be.
+ text = pubkey.hexToNpubHrp(),
+ isMonospaced = true,
+ removeDescription = "Remove suggester",
+ onRemove = { editGroupCuratedSchemaViewModel.removeSuggester(pubkey) }
+ )
+ }
+
+ item {
+ AddRow(
+ state = suggesterFieldState,
+ icon = Icons.Default.PersonAdd,
+ label = stringResource(Res.string.npub_or_hex_pubkey),
+ placeholder = null,
+ addDescription = "Add suggester",
+ failure = when (editGroupCuratedSchemaViewModel.addSuggesterFailure) {
+ AddSuggesterFailure.NotAPubkey ->
+ stringResource(Res.string.that_is_not_a_pubkey)
+ AddSuggesterFailure.AlreadyListed ->
+ stringResource(Res.string.that_pubkey_is_already_a_suggester)
+ null -> null
+ },
+ onAdd = { input -> editGroupCuratedSchemaViewModel.addSuggester(input) }
+ )
+ }
+ }
+
+ item {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(
+ MaterialTheme.spacing.relatedGap
+ )
+ ) {
+ SectionHeading(stringResource(Res.string.relays))
+
+ Text(
+ text = stringResource(
+ Res.string.where_suggestions_to_this_list_are_published
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ if (editGroupCuratedSchemaViewModel.relays.isEmpty()) {
+ Text(
+ text = stringResource(
+ Res.string.no_relays_named_so_a_client_will_pick_its_own
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+
+ items(editGroupCuratedSchemaViewModel.relays) { url ->
+ RemovableRow(
+ text = url,
+ isMonospaced = false,
+ removeDescription = "Remove relay",
+ onRemove = { editGroupCuratedSchemaViewModel.removeRelay(url) }
+ )
+ }
+
+ item {
+ AddRow(
+ state = relayFieldState,
+ icon = Icons.Default.Dns,
+ label = stringResource(Res.string.relay_url),
+ placeholder = stringResource(Res.string.eg_wss_relay_example_com),
+ addDescription = "Add relay",
+ failure = when (editGroupCuratedSchemaViewModel.addRelayFailure) {
+ AddRelayFailure.NotARelay ->
+ stringResource(Res.string.that_is_not_a_relay_address)
+ AddRelayFailure.AlreadyListed ->
+ stringResource(Res.string.that_relay_is_already_in_this_list)
+ null -> null
+ },
+ onAdd = { input -> editGroupCuratedSchemaViewModel.addRelay(input) }
+ )
+ }
+ }
+ }
+
+ editGroupCuratedSchemaViewModel.editingField?.let { edit ->
+ FieldEditorSheet(
+ edit = edit,
+ isMandatory = editGroupCuratedSchemaViewModel.isMandatory(edit.field),
+ onCommit = { field -> editGroupCuratedSchemaViewModel.commitField(field) },
+ onRemove = { edit.index?.let(editGroupCuratedSchemaViewModel::removeField) },
+ onDismiss = { editGroupCuratedSchemaViewModel.dismissFieldEditor() }
+ )
+ }
+ }
+
+ EditGroupCuratedSchemaUIState.Loading -> {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
+ ) {
+ Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
+
+ Text(
+ text = stringResource(
+ if (identifier == null) Res.string.add_schema else Res.string.edit_schema
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center
+ )
+
+ LoadingDataIndicator(fillScreen = false)
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(true) {
+ if (initialEditGroupCuratedSchemaUIState == EditGroupCuratedSchemaUIState.Loading) {
+ editGroupCuratedSchemaViewModel.initiateEditGroupCuratedSchema()
+ }
+ }
+}
+
+/** The word for who may suggest, for the group's screen and the editor alike. */
+internal fun CuratedVisibility.label(): StringResource = when (this) {
+ CuratedVisibility.Public -> Res.string.visibility_public
+ CuratedVisibility.Closed -> Res.string.visibility_closed
+ CuratedVisibility.Private -> Res.string.visibility_private
+}
+
+/** The sentence saying what the word decides. */
+private fun CuratedVisibility.purpose(): StringResource = when (this) {
+ CuratedVisibility.Public -> Res.string.visibility_public_purpose
+ CuratedVisibility.Closed -> Res.string.visibility_closed_purpose
+ CuratedVisibility.Private -> Res.string.visibility_private_purpose
+}
+
+/**
+ * The name of a field type, for the chips and the rows.
+ *
+ * A `when` rather than a field on each entry so that the enum stays what it is
+ * -- the NIP's nine words and their one rule each -- with no catalogue behind
+ * it, and so that a tenth type is a compile error here rather than a chip with
+ * no name.
+ */
+private fun CuratedFieldType.label(): StringResource = when (this) {
+ CuratedFieldType.Text -> Res.string.field_type_text
+ CuratedFieldType.LongText -> Res.string.field_type_longtext
+ CuratedFieldType.Token -> Res.string.field_type_token
+ CuratedFieldType.Url -> Res.string.field_type_url
+ CuratedFieldType.Image -> Res.string.field_type_image
+ CuratedFieldType.Enum -> Res.string.field_type_enum
+ CuratedFieldType.Year -> Res.string.field_type_year
+ CuratedFieldType.Duration -> Res.string.field_type_duration
+ CuratedFieldType.Number -> Res.string.field_type_number
+}
+
+/**
+ * Why a schema cannot be proposed, said to the member.
+ *
+ * The caps are quoted from `CuratedSchemaEvent` rather than repeated, so the
+ * sentence and the check cannot disagree about the number.
+ */
+@Composable
+private fun CuratedSchemaProblem.message(): String = when (this) {
+ CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required)
+ CuratedSchemaProblem.IdentifierTooLong -> stringResource(
+ Res.string.the_identifier_must_be_at_most_n_characters,
+ CuratedSchemaEvent.MAX_IDENTIFIER.toString()
+ )
+ CuratedSchemaProblem.TitleMissing -> stringResource(Res.string.a_title_is_required)
+ CuratedSchemaProblem.TitleTooLong -> stringResource(
+ Res.string.the_title_must_be_at_most_n_characters,
+ CuratedSchemaEvent.MAX_TITLE.toString()
+ )
+ CuratedSchemaProblem.NameMissing -> stringResource(Res.string.a_name_is_required)
+ CuratedSchemaProblem.NameTooLong -> stringResource(
+ Res.string.the_name_must_be_at_most_n_characters,
+ CuratedSchemaEvent.MAX_NAME.toString()
+ )
+ CuratedSchemaProblem.DescriptionMissing -> stringResource(Res.string.a_description_is_required)
+ CuratedSchemaProblem.DescriptionTooLong -> stringResource(
+ Res.string.the_description_must_be_at_most_n_characters,
+ CuratedSchemaEvent.MAX_DESCRIPTION.toString()
+ )
+ CuratedSchemaProblem.VisibilityMissing -> stringResource(Res.string.say_who_may_suggest_to_this_list)
+ CuratedSchemaProblem.PictureNotHttps -> stringResource(Res.string.the_picture_must_be_an_https_url)
+ CuratedSchemaProblem.DomainInvalid -> stringResource(Res.string.that_is_not_a_domain_name)
+ CuratedSchemaProblem.NoFields -> stringResource(Res.string.a_schema_needs_at_least_one_field)
+ CuratedSchemaProblem.RelayInvalid -> stringResource(Res.string.one_of_the_relays_is_not_a_relay_address)
+}
+
+/** Whether any of [problems] is about the field asking, for its error state. */
+private fun List.containsAny(vararg problems: CuratedSchemaProblem): Boolean =
+ problems.any { it in this }
+
+/** A heading over one part of the form, at the leading edge like the rows under it. */
+@Composable
+private fun SectionHeading(text: String) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = text,
+ style = MaterialTheme.typography.titleSmall
+ )
+}
+
+/**
+ * One text field of the form, in the shape the app's other forms use.
+ *
+ * The borderless outlined field over the bottom bar's tint is four lines of
+ * colours apiece, and this screen has more of them than any other -- so the
+ * function, and the three things this one adds over `NostrProfileField`: a
+ * read-only state for the locked identifier, an error state for a field a
+ * problem is about, and a line of supporting text for a field that needs one.
+ */
+@Composable
+private fun SchemaTextField(
+ state: TextFieldState,
+ icon: ImageVector,
+ iconDescription: String,
+ label: String,
+ placeholder: String,
+ lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine,
+ readOnly: Boolean = false,
+ isError: Boolean = false,
+ supportingText: String? = null
+) {
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor),
+ state = state,
+ lineLimits = lineLimits,
+ readOnly = readOnly,
+ isError = isError,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent,
+ disabledBorderColor = Color.Transparent
+ ),
+ leadingIcon = {
+ Icon(icon, contentDescription = iconDescription)
+ },
+ label = {
+ Text(text = label)
+ },
+ placeholder = {
+ Text(text = placeholder)
+ },
+ supportingText = supportingText?.let { { Text(text = it) } }
+ )
+}
+
+/**
+ * One field of the schema, as a row that opens the sheet.
+ *
+ * The label is what a suggester will see over the input, so it is the headline;
+ * the name, type and requirement are what the schema says about it. A
+ * mandatory field is marked as required whatever its own flag says, since that
+ * is what will be signed.
+ */
+@Composable
+private fun SchemaFieldRow(
+ field: CuratedField,
+ isMandatory: Boolean,
+ onOpen: () -> Unit
+) {
+ Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) {
+ ListItem(
+ leadingContent = {
+ Icon(Icons.Default.Ballot, contentDescription = Decorative)
+ },
+ headlineContent = {
+ Text(
+ text = field.labelOrName(),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Text(
+ text = listOfNotNull(
+ field.name,
+ stringResource(field.type.label()),
+ stringResource(
+ if (field.isRequired || isMandatory) Res.string.required else Res.string.optional
+ ),
+ stringResource(Res.string.derived).takeIf { field.config.derived == true }
+ ).joinToString(" · "),
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ )
+ }
+}
+
+/**
+ * One `require-any` rule: a chip per field, selected for the ones in the rule.
+ *
+ * Chips over a picker because the whole point of a rule is which fields are in
+ * it, and a chip row shows that at a glance where a picker would show a count.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun RequireAnyRow(
+ fieldNames: List,
+ selected: List,
+ onToggle: (String) -> Unit,
+ onRemove: () -> Unit
+) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ FlowRow(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ fieldNames.forEach { fieldName ->
+ FilterChip(
+ selected = fieldName in selected,
+ onClick = { onToggle(fieldName) },
+ label = { Text(fieldName) }
+ )
+ }
+ }
+
+ IconButton(onClick = onRemove) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Remove rule",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+}
+
+/** One entry of a plain list -- a relay, a suggester -- with the way to drop it. */
+@Composable
+private fun RemovableRow(
+ text: String,
+ isMonospaced: Boolean,
+ removeDescription: String,
+ onRemove: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = if (isMonospaced) FontFamily.Monospace else null
+ )
+
+ IconButton(onClick = onRemove) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = removeDescription,
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+}
+
+/**
+ * A field and a button that adds what is in it to a list, and the reason when
+ * it would not.
+ *
+ * [onAdd] answers whether the row appeared, which is the cue to clear the field
+ * -- a refused value stays where it can be corrected.
+ */
+@Composable
+private fun AddRow(
+ state: TextFieldState,
+ icon: ImageVector,
+ label: String,
+ placeholder: String?,
+ addDescription: String,
+ failure: String?,
+ onAdd: (String) -> Boolean
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.weight(1f).background(BottomAppBarDefaults.containerColor),
+ state = state,
+ lineLimits = TextFieldLineLimits.SingleLine,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color.Transparent,
+ unfocusedBorderColor = Color.Transparent,
+ disabledBorderColor = Color.Transparent
+ ),
+ leadingIcon = {
+ Icon(icon, contentDescription = Decorative)
+ },
+ label = { Text(label) },
+ placeholder = placeholder?.let { { Text(it) } },
+ isError = failure != null
+ )
+
+ Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
+
+ IconButton(
+ onClick = {
+ if (onAdd(state.text.toString())) {
+ state.clearText()
+ }
+ }
+ ) {
+ Icon(Icons.Default.Add, contentDescription = addDescription)
+ }
+ }
+
+ failure?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+}
+
+/**
+ * The sheet a field is edited on.
+ *
+ * Split into the sheet and its content the way `GroupKeyStateSheet` is, so the
+ * content can be rendered and measured on its own -- a bottom sheet is a popup
+ * with its own window, which a layout test cannot reach into.
+ */
+@Composable
+private fun FieldEditorSheet(
+ edit: FieldEdit,
+ isMandatory: Boolean,
+ onCommit: (CuratedField) -> FieldProblem?,
+ onRemove: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ ModalBottomSheet(onDismiss = onDismiss, skipPartiallyExpanded = true) {
+ FieldEditorSheetContent(
+ edit = edit,
+ isMandatory = isMandatory,
+ onCommit = onCommit,
+ onRemove = onRemove
+ )
+ }
+}
+
+/**
+ * Everything a field can say about itself, and a button that puts it in the
+ * schema.
+ *
+ * The positional parts of the tag first -- name, label, placeholder, type,
+ * required -- because they are what a form needs; the config keys after, and
+ * only the ones the chosen type gives a meaning to. A minimum on a text field or
+ * options on a year would be written into the event and mean nothing, so they
+ * are not offered and, when the type changes away from them, not kept.
+ *
+ * **A mandatory field cannot stop being one.** Its tag is read-only and its
+ * requirement is on and disabled, because `CuratedSchema.normalized` would only
+ * put a field writing to `d` or `title` back if this let it be taken away -- so
+ * offering the change would offer something the event will not carry.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+internal fun FieldEditorSheetContent(
+ edit: FieldEdit,
+ isMandatory: Boolean,
+ onCommit: (CuratedField) -> FieldProblem?,
+ onRemove: () -> Unit
+) {
+ val field = edit.field
+
+ val nameFieldState = rememberTextFieldState(field.name)
+ val labelFieldState = rememberTextFieldState(field.label)
+ val placeholderFieldState = rememberTextFieldState(field.placeholder)
+ val tagFieldState = rememberTextFieldState(field.config.tag ?: "")
+ val markerFieldState = rememberTextFieldState(field.config.marker ?: "")
+ val maxFieldState = rememberTextFieldState(field.config.max?.toString() ?: "")
+ val minFieldState = rememberTextFieldState(field.config.min?.toString() ?: "")
+ val optionsFieldState = rememberTextFieldState(field.config.options?.joinToString("\n") ?: "")
+ val patternFieldState = rememberTextFieldState(field.config.pattern ?: "")
+ val hintFieldState = rememberTextFieldState(field.config.hint ?: "")
+
+ var type by rememberSaveable { mutableStateOf(field.type) }
+ var isRequired by rememberSaveable { mutableStateOf(field.isRequired || isMandatory) }
+ var mayRepeat by rememberSaveable { mutableStateOf(field.config.repeat == true) }
+ var isHttpsOnly by rememberSaveable { mutableStateOf(field.config.https == true) }
+ var isDerived by rememberSaveable { mutableStateOf(field.config.derived == true) }
+
+ var problem: FieldProblem? by remember { mutableStateOf(null) }
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
+ ) {
+ Text(
+ text = stringResource(
+ if (edit.index == null) Res.string.new_field else Res.string.edit_field
+ ),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ SchemaTextField(
+ state = nameFieldState,
+ icon = Icons.Default.Tag,
+ iconDescription = "Name of the field",
+ label = stringResource(Res.string.field_name),
+ placeholder = stringResource(Res.string.eg_watchurl),
+ isError = problem != null
+ )
+
+ // Named rather than silent, under the field it is about.
+ problem?.let {
+ Text(
+ text = when (it) {
+ FieldProblem.NameMissing -> stringResource(Res.string.a_field_needs_a_name)
+ FieldProblem.NameHasSpaces -> stringResource(Res.string.a_field_name_is_one_word)
+ FieldProblem.NameTaken -> stringResource(Res.string.a_field_named_that_already_exists)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+
+ SchemaTextField(
+ state = labelFieldState,
+ icon = Icons.AutoMirrored.Filled.Label,
+ iconDescription = "Label over the input",
+ label = stringResource(Res.string.label),
+ placeholder = stringResource(Res.string.eg_watch_reference_url)
+ )
+
+ SchemaTextField(
+ state = placeholderFieldState,
+ icon = Icons.AutoMirrored.Filled.ShortText,
+ iconDescription = "Example shown in an empty input",
+ label = stringResource(Res.string.placeholder),
+ placeholder = stringResource(Res.string.eg_https_youtube_com_watch)
+ )
+
+ Text(
+ text = stringResource(Res.string.type),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
+ ) {
+ CuratedFieldType.entries.forEach { option ->
+ FilterChip(
+ selected = option == type,
+ onClick = { type = option },
+ label = { Text(stringResource(option.label())) }
+ )
+ }
+ }
+
+ SwitchRow(
+ label = stringResource(Res.string.an_entry_must_have_this_field),
+ checked = isRequired,
+ enabled = !isMandatory,
+ onCheckedChange = { isRequired = it }
+ )
+
+ SchemaTextField(
+ state = tagFieldState,
+ icon = Icons.Default.Sell,
+ iconDescription = "Tag the field writes to",
+ label = stringResource(Res.string.writes_to_tag),
+ placeholder = nameFieldState.text.toString().ifBlank { stringResource(Res.string.eg_watchurl) },
+ readOnly = isMandatory,
+ supportingText = stringResource(
+ Res.string.defaults_to_the_field_name_content_means_the_body
+ )
+ )
+
+ SchemaTextField(
+ state = markerFieldState,
+ icon = Icons.Default.Bookmark,
+ iconDescription = "Marker on the tag",
+ label = stringResource(Res.string.marker),
+ placeholder = stringResource(Res.string.eg_watch),
+ supportingText = stringResource(
+ Res.string.the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart
+ )
+ )
+
+ SchemaTextField(
+ state = maxFieldState,
+ icon = Icons.Default.Numbers,
+ iconDescription = "Maximum",
+ label = stringResource(
+ if (type.isNumeric) Res.string.maximum_value else Res.string.maximum_characters
+ ),
+ placeholder = stringResource(if (type.isNumeric) Res.string.eg_2100 else Res.string.eg_200)
+ )
+
+ if (type.isNumeric) {
+ SchemaTextField(
+ state = minFieldState,
+ icon = Icons.Default.Numbers,
+ iconDescription = "Minimum",
+ label = stringResource(Res.string.minimum_value),
+ placeholder = stringResource(Res.string.eg_1900)
+ )
+ }
+
+ if (type == CuratedFieldType.Enum) {
+ SchemaTextField(
+ state = optionsFieldState,
+ icon = Icons.Default.Checklist,
+ iconDescription = "Allowed values",
+ label = stringResource(Res.string.options),
+ placeholder = stringResource(Res.string.eg_movie_documentary_short),
+ lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 6),
+ supportingText = stringResource(Res.string.one_per_line_or_separated_by_commas)
+ )
+ }
+
+ if (type == CuratedFieldType.Url) {
+ SwitchRow(
+ label = stringResource(Res.string.https_only),
+ checked = isHttpsOnly,
+ enabled = true,
+ onCheckedChange = { isHttpsOnly = it }
+ )
+ }
+
+ SwitchRow(
+ label = stringResource(Res.string.may_appear_more_than_once),
+ checked = mayRepeat,
+ enabled = true,
+ onCheckedChange = { mayRepeat = it }
+ )
+
+ SchemaTextField(
+ state = patternFieldState,
+ icon = Icons.AutoMirrored.Filled.Rule,
+ iconDescription = "Pattern the value must match",
+ label = stringResource(Res.string.pattern),
+ placeholder = stringResource(Res.string.eg_a_za_z_2_8),
+ supportingText = stringResource(
+ Res.string.a_regular_expression_the_whole_value_must_match
+ )
+ )
+
+ SwitchRow(
+ label = stringResource(Res.string.filled_in_by_the_app_never_asked_for),
+ checked = isDerived,
+ enabled = true,
+ onCheckedChange = { isDerived = it }
+ )
+
+ SchemaTextField(
+ state = hintFieldState,
+ icon = Icons.Default.Lightbulb,
+ iconDescription = "Helper text",
+ label = stringResource(Res.string.hint),
+ placeholder = stringResource(Res.string.helper_text_under_the_input)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (!isMandatory && edit.index != null) {
+ TextButton(
+ onClick = onRemove,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text(stringResource(Res.string.remove_field))
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Button(
+ onClick = {
+ problem = onCommit(
+ CuratedField(
+ name = nameFieldState.text.toString().trim(),
+ type = type,
+ isRequired = isRequired || isMandatory,
+ placeholder = placeholderFieldState.text.toString().trim(),
+ label = labelFieldState.text.toString().trim(),
+ config = field.config.copy(
+ // Pinned for a mandatory field, so that renaming it
+ // cannot move it off the tag that makes it one.
+ tag = if (isMandatory) {
+ field.tag()
+ } else {
+ tagFieldState.text.toString().trim().ifEmpty { null }
+ },
+ marker = markerFieldState.text.toString().trim().ifEmpty { null },
+ max = maxFieldState.text.toString().trim().toLongOrNull(),
+ min = if (type.isNumeric) {
+ minFieldState.text.toString().trim().toLongOrNull()
+ } else {
+ null
+ },
+ options = if (type == CuratedFieldType.Enum) {
+ optionsFieldState.text.toString()
+ .split('\n', ',')
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .ifEmpty { null }
+ } else {
+ null
+ },
+ https = if (type == CuratedFieldType.Url && isHttpsOnly) true else null,
+ repeat = if (mayRepeat) true else null,
+ pattern = patternFieldState.text.toString().trim().ifEmpty { null },
+ derived = if (isDerived) true else null,
+ hint = hintFieldState.text.toString().trim().ifEmpty { null },
+ )
+ )
+ )
+ }
+ ) {
+ Text(stringResource(Res.string.done))
+ }
+ }
+ }
+}
+
+/**
+ * A labelled switch, the label taking the width and the switch its own target.
+ *
+ * The label is also the switch's description, so a screen reader announces what
+ * is being turned on rather than "switch, off" beside a sentence it has already
+ * read past.
+ */
+@Composable
+private fun SwitchRow(
+ label: String,
+ checked: Boolean,
+ enabled: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = label,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Switch(
+ modifier = Modifier.semantics { contentDescription = label },
+ checked = checked,
+ enabled = enabled,
+ onCheckedChange = onCheckedChange
+ )
+ }
+}
+
+@ConformancePreviews
+@Composable
+private fun EditGroupCuratedSchemaScreenPreview() {
+ MantraTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ EditGroupCuratedSchemaScreen(
+ activeUserPublicKey = "",
+ chatRoomId = "publicKey",
+ relayHint = null,
+ identifier = null,
+ initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = "publicKey",
+ userPublicKey = "",
+ subject = "Translation room",
+ description = "A group translating hard books.",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = "state"
+ ),
+ ),
+ // A new schema, so the editor seeds the two fields every list
+ // has and the preview shows what a group actually starts from
+ // rather than an empty form.
+ defaultRelays = listOf("wss://relay.example.com"),
+ // A group that can sign, so the form renders in the state a
+ // member actually meets it in rather than greyed out.
+ canSign = true
+ ),
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ onNavigateToRouteAndPopUpInclusive = {}
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt
index d3be59ff..773b6dad 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.EditGroupCuratedSchemaScreen
import press.mantra.compose.ui.composable.EditGroupRelaysScreen
import press.mantra.compose.ui.composable.HomeScreen
import press.mantra.compose.ui.composable.ImplementationPendingScreen
@@ -127,6 +128,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.EditGroupCuratedSchemaRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
@@ -1034,6 +1036,27 @@ fun MantraNavHost(
}
)
}
+ composable { backStackEntry ->
+ val route = backStackEntry.toRoute()
+
+ EditGroupCuratedSchemaScreen(
+ activeUserPublicKey = route.activeUserPublicKey,
+ chatRoomId = route.chatRoomId,
+ relayHint = route.relayHint,
+ identifier = route.identifier,
+ chatRepository = databaseChatRepository,
+ frostSigningRepository = databaseFrostSigningRepository,
+ onNavigateToRouteAndPopUpInclusive = { signingRoute ->
+ // Replace the editor so back returns to the group rather
+ // than to a schema whose proposal has already gone out.
+ navController.navigate(route = signingRoute) {
+ popUpTo {
+ inclusive = true
+ }
+ }
+ }
+ )
+ }
composable { backStackEntry ->
val route = backStackEntry.toRoute()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt
new file mode 100644
index 00000000..a56bf15b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt
@@ -0,0 +1,20 @@
+package press.mantra.compose.ui.composable.navigation.routes
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The editor for one of a group's curated schemas.
+ *
+ * [identifier] is the schema's `d`, and null opens the editor on a new one. It
+ * is the identifier rather than the event id because kind 31889 is addressable:
+ * an edit replaces the event under the same `d`, and the editor's job is to
+ * propose that replacement, so the coordinate is what names the thing being
+ * edited and the event is only its current version.
+ */
+@Serializable
+data class EditGroupCuratedSchemaRoute(
+ val activeUserPublicKey: String,
+ val chatRoomId: String, // TODO: have this as a publicKey
+ val relayHint: String?,
+ val identifier: String?
+): Route()
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
index 144507b2..7844c5ce 100644
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt
@@ -91,6 +91,7 @@ class ChatRoomDetailViewModel(
groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
groupRelayLists = chatRepository.groupRelayLists(chatRoomId),
groupPosts = chatRepository.groupPosts(chatRoomId),
+ groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId),
canEditNostrProfile = canSign,
canAddSubgroup = canSign,
)
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt
new file mode 100644
index 00000000..03847a12
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt
@@ -0,0 +1,544 @@
+package press.mantra.compose.ui.view.model
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import co.touchlab.kermit.Logger
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.extensions.bech32ToHexOrNull
+import press.mantra.compose.nostr.GroupCuratedSchema
+import press.mantra.compose.nostr.GroupRelaySet
+import press.mantra.compose.nostr.curated.CuratedField
+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.CuratedSchemaProblem
+import press.mantra.compose.nostr.curated.CuratedVisibility
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.launch
+
+/**
+ * One of the group's curated schemas, edited locally and proposed as one event.
+ *
+ * Nothing here is saved. The parts of the schema that are lists -- its fields,
+ * its rules, its suggesters, its relays -- live on this view model as snapshot
+ * state so that a bottom sheet opening over the form, or the window turning,
+ * does not throw an edit away; the identity fields are text and live in the
+ * composition like every other form's. The button folds both halves into a
+ * [CuratedSchema], refuses it for any reason a reader would, and otherwise opens
+ * one signing session over it. The schema changes on every member's device at
+ * once, when a quorum has signed, or not at all.
+ *
+ * ### Edit and replace
+ *
+ * Kind 31889 is addressable, so proposing a schema under an identifier the group
+ * already signed one for *replaces* it -- which is what an edit is, and why the
+ * identifier is the one thing the editor will not let an edit change. A new
+ * identifier is a new list, with a queue of its own, and the group's screen
+ * offers that as adding a schema rather than as editing this one.
+ *
+ * The comparison for "nothing changed" is against the schema as the group signed
+ * it, normalized on both sides, because re-signing an identical schema would put
+ * a second statement on the record with a newer timestamp: harmless, since the
+ * kind is replaceable, and still a lie about when the group last decided
+ * anything about the list.
+ */
+class EditGroupCuratedSchemaViewModel(
+ val chatRoomId: String,
+ val activeUserPublicKey: HexKey,
+ val relayHint: String?,
+ /** The `d` of the schema being edited, or null for a new one. */
+ val identifier: String?,
+ initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState,
+ val chatRepository: ChatRepository,
+ val frostSigningRepository: FrostSigningRepository,
+): ViewModel() {
+
+ var editGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState by mutableStateOf(
+ initialEditGroupCuratedSchemaUIState
+ )
+ private set
+
+ private val logger = Logger.withTag(TAG)
+
+ val isActionPending: MutableState = mutableStateOf(false)
+
+ /** Who may suggest. Always set: the editor never proposes a schema without one. */
+ var visibility: CuratedVisibility by mutableStateOf(CuratedVisibility.Public)
+ private set
+
+ /** The working copy of the schema's fields, in the order they will be written. */
+ val fields = mutableStateListOf()
+
+ /**
+ * The working copy of the `require-any` rules, each a set of field names.
+ *
+ * A rule may hold fewer than two names while it is being built; one that
+ * still does when the button is pressed says nothing and is dropped rather
+ * than refused -- see `CuratedSchema.requireAny`.
+ */
+ val requireAny = mutableStateListOf>()
+
+ /** The working copy of the `p` tags: pubkeys besides the group that may suggest. */
+ val suggesters = mutableStateListOf()
+
+ /** The working copy of the `relay` tags. */
+ val relays = mutableStateListOf()
+
+ /** The field the sheet is open on, or null while it is closed. */
+ var editingField: FieldEdit? by mutableStateOf(null)
+ private set
+
+ /**
+ * 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
+
+ /** Why the last relay was refused, or null. Cleared by the next keystroke. */
+ var addRelayFailure: AddRelayFailure? by mutableStateOf(null)
+ private set
+
+ /** Why the last suggester was refused, or null. Cleared by the next keystroke. */
+ var addSuggesterFailure: AddSuggesterFailure? by mutableStateOf(null)
+ private set
+
+ private var isSeeded = false
+
+ /**
+ * A field on the sheet. [index] is its place in [fields], or null for one
+ * being added -- which is also what decides whether committing it appends
+ * or replaces.
+ */
+ data class FieldEdit(val index: Int?, val field: CuratedField)
+
+ enum class AddRelayFailure {
+ /** Not a `wss://` URL, or one pointing at this machine. */
+ NotARelay,
+
+ /** Already named, which is a no-op rather than a mistake. */
+ AlreadyListed,
+ }
+
+ enum class AddSuggesterFailure {
+ /** Neither an npub nor 64 hex characters. */
+ NotAPubkey,
+
+ AlreadyListed,
+ }
+
+ /** Why a field cannot be committed as it stands. */
+ enum class FieldProblem {
+ NameMissing,
+
+ /** A field name is the key a form collects a value under, and a key is one word. */
+ NameHasSpaces,
+
+ /** Another field already answers to that name. */
+ NameTaken,
+ }
+
+ fun initiateEditGroupCuratedSchema() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
+
+ editGroupCuratedSchemaUIState = if (localChatRoom == null) {
+ EditGroupCuratedSchemaUIState.Error("Couldn't find the chat room")
+ } else {
+ val existing = identifier?.let { wanted ->
+ chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == wanted }
+ }
+
+ if (identifier != null && existing == null) {
+ // Opened on a schema this device does not hold. Nothing to
+ // retry and nothing to edit; offering a blank form under that
+ // identifier would propose a replacement for a list nobody
+ // here has read.
+ EditGroupCuratedSchemaUIState.Error("Couldn't find that schema")
+ } else {
+ EditGroupCuratedSchemaUIState.Loaded(
+ localChatRoom = localChatRoom,
+ existing = existing,
+ defaultRelays = defaultRelays(chatRoomId),
+ canSign = localChatRoom.chatRoom.mlsGroupState != null &&
+ frostSigningRepository.canSign(chatRoomId),
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Where a new schema says its list lives: the relays the group has said it
+ * writes to, since that is where its canonical entries would be read from.
+ *
+ * The NIP-65 list where the group has agreed one, else that set's defaults
+ * -- which is this build's own relay, and the same thing a group opening the
+ * relay editor for the first time is shown. A schema with no relays is one
+ * whose suggestions could go anywhere, so the seed is worth getting right.
+ */
+ private suspend fun defaultRelays(chatRoomId: String): List {
+ val general = chatRepository.groupRelayLists(chatRoomId)
+ .firstOrNull { it.set == GroupRelaySet.General }
+ ?: return GroupRelaySet.General.defaults().map { it.url.url }
+
+ return (if (general.isAgreed) general.relays else general.set.defaults())
+ .filter { it.write }
+ .map { it.url.url }
+ }
+
+ /**
+ * Fills the working copy from the schema the group signed, once.
+ *
+ * An existing schema starts at itself. A new one starts at the two fields
+ * every list has -- the identifier and the title, which `CuratedSchema.normalized`
+ * would put back anyway, shown up front so that what the member sees is what
+ * the group will sign -- and at the group's own relays.
+ *
+ * Called from the screen rather than from [initiateEditGroupCuratedSchema],
+ * 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: EditGroupCuratedSchemaUIState.Loaded) {
+ if (isSeeded) return
+ isSeeded = true
+
+ val schema = state.existing?.schema
+ if (schema != null) {
+ visibility = schema.visibility ?: CuratedVisibility.Public
+ fields.addAll(schema.fields)
+ requireAny.addAll(schema.requireAny)
+ suggesters.addAll(schema.suggesters)
+ relays.addAll(schema.relays)
+ } else {
+ visibility = CuratedVisibility.Public
+ fields.addAll(listOf(CuratedField.IDENTIFIER, CuratedField.TITLE))
+ relays.addAll(state.defaultRelays)
+ }
+ }
+
+ fun selectVisibility(visibility: CuratedVisibility) {
+ this.visibility = visibility
+ }
+
+ /**
+ * Whether [field] is one of the two every list must have.
+ *
+ * Decided by the tag it writes to, not its name, because that is what the
+ * NIP's rule is about: a list needs a field writing to `d` and one writing
+ * to `title`, whatever they are called. Such a field cannot be removed or
+ * made optional here -- `CuratedSchema.normalized` would only put it back.
+ */
+ fun isMandatory(field: CuratedField): Boolean =
+ CuratedField.MANDATORY.any { (tag, _) -> field.tag() == tag }
+
+ /** Opens the sheet on a blank field. Text, because most fields are. */
+ fun startAddingField() {
+ editingField = FieldEdit(
+ index = null,
+ field = CuratedField(name = "", type = CuratedFieldType.Text, isRequired = false),
+ )
+ }
+
+ fun startEditingField(index: Int) {
+ fields.getOrNull(index)?.let { editingField = FieldEdit(index = index, field = it) }
+ }
+
+ fun dismissFieldEditor() {
+ editingField = null
+ }
+
+ /**
+ * Puts [field] into the working copy at the place the sheet was opened on,
+ * or says why it cannot.
+ *
+ * Null means it went in and the sheet is closed. A rename follows through to
+ * the rules that named the field, since a rule naming a field that no longer
+ * exists would silently never be satisfied.
+ */
+ fun commitField(field: CuratedField): FieldProblem? {
+ val edit = editingField ?: return null
+
+ val name = field.name.trim()
+ if (name.isEmpty()) return FieldProblem.NameMissing
+ if (name.any { it.isWhitespace() }) return FieldProblem.NameHasSpaces
+ if (fields.withIndex().any { (index, other) -> index != edit.index && other.name == name }) {
+ return FieldProblem.NameTaken
+ }
+
+ val committed = field.copy(name = name)
+
+ if (edit.index == null) {
+ fields.add(committed)
+ } else {
+ val previous = fields[edit.index]
+ fields[edit.index] = committed
+ if (previous.name != name) {
+ requireAny.indices.forEach { group ->
+ requireAny[group] = requireAny[group].map { if (it == previous.name) name else it }
+ }
+ }
+ }
+
+ editingField = null
+ return null
+ }
+
+ /** Drops the field at [index] and takes it out of any rule that named it. */
+ fun removeField(index: Int) {
+ val removed = fields.getOrNull(index) ?: return
+ if (isMandatory(removed)) return
+
+ fields.removeAt(index)
+ requireAny.indices.forEach { group ->
+ requireAny[group] = requireAny[group].filterNot { it == removed.name }
+ }
+ editingField = null
+ }
+
+ fun addRequireAnyGroup() {
+ requireAny.add(emptyList())
+ }
+
+ fun toggleRequireAny(group: Int, fieldName: String) {
+ val names = requireAny.getOrNull(group) ?: return
+ requireAny[group] = if (fieldName in names) names - fieldName else names + fieldName
+ }
+
+ fun removeRequireAnyGroup(group: Int) {
+ if (group in requireAny.indices) requireAny.removeAt(group)
+ }
+
+ /**
+ * Adds [input] to the suggesters, or says why it did not.
+ *
+ * True when the row appeared, which is the caller's cue to clear the field.
+ */
+ fun addSuggester(input: String): Boolean {
+ val pubkey = pubkeyOrNull(input) ?: run {
+ addSuggesterFailure = AddSuggesterFailure.NotAPubkey
+ return false
+ }
+
+ if (pubkey in suggesters) {
+ addSuggesterFailure = AddSuggesterFailure.AlreadyListed
+ return false
+ }
+
+ suggesters.add(pubkey)
+ addSuggesterFailure = null
+ return true
+ }
+
+ fun removeSuggester(pubkey: HexKey) {
+ suggesters.remove(pubkey)
+ }
+
+ fun clearAddSuggesterFailure() {
+ addSuggesterFailure = null
+ }
+
+ /**
+ * Adds [url] to the relays, or says why it did not.
+ *
+ * Held to the same rule as the group's relay lists -- `wss://`, not this
+ * machine -- rather than to the NIP's looser one, because this is a relay
+ * the group is about to sign for strangers to publish to. See
+ * `GroupRelaySet.relayUrlOrNull`.
+ */
+ fun addRelay(url: String): Boolean {
+ val relay = GroupRelaySet.relayUrlOrNull(url) ?: run {
+ addRelayFailure = AddRelayFailure.NotARelay
+ return false
+ }
+
+ // Compared normalized, because a relay signed into an existing schema is
+ // kept as it was written and the normalizer adds a trailing slash: the
+ // same host with and without one is one relay, not two.
+ if (relays.any { GroupRelaySet.relayUrlOrNull(it) == relay }) {
+ addRelayFailure = AddRelayFailure.AlreadyListed
+ return false
+ }
+
+ relays.add(relay.url)
+ addRelayFailure = null
+ return true
+ }
+
+ fun removeRelay(url: String) {
+ relays.remove(url)
+ }
+
+ fun clearAddRelayFailure() {
+ addRelayFailure = null
+ }
+
+ /**
+ * The schema as the form has it: the text fields trimmed and the lists as
+ * they stand, with a rule too short to mean anything left out.
+ */
+ fun draft(
+ identifier: String,
+ title: String,
+ name: String,
+ description: String,
+ picture: String,
+ domain: String,
+ ): CuratedSchema = CuratedSchema(
+ identifier = identifier.trim(),
+ title = title.trim(),
+ name = name.trim(),
+ description = description.trim(),
+ visibility = visibility,
+ picture = picture.trim().ifEmpty { null },
+ domain = domain.trim().ifEmpty { null }?.let(CuratedSchemaEvent::normalizeDomain),
+ fields = fields.toList(),
+ requireAny = requireAny.filter { it.size > 1 },
+ suggesters = suggesters.toList(),
+ relays = relays.toList(),
+ )
+
+ /**
+ * Opens a session over the schema, or says why not.
+ *
+ * Three refusals before anything is proposed and each is the caller's to
+ * say: a schema a reader would reject, which is [onInvalid] with the
+ * reasons; an edit that changed nothing, which is [onNothingToPropose]; and
+ * a proposal that could not be opened, which is [onFailure]. All of them
+ * stay on the form with the edits still in it.
+ */
+ fun proposeSchema(
+ localChatRoom: LocalChatRoom,
+ existing: GroupCuratedSchema?,
+ identifier: String,
+ title: String,
+ name: String,
+ description: String,
+ picture: String,
+ domain: String,
+ onSuccess: (sessionId: String) -> Unit,
+ onInvalid: () -> Unit,
+ onNothingToPropose: () -> Unit,
+ onFailure: () -> Unit
+ ) {
+ // Guard against double submits. Two sessions over two versions of one
+ // schema would leave the list's form decided by whichever quorum
+ // finished last.
+ if (isActionPending.value) return
+
+ val schema = draft(
+ // The identifier of an existing schema is the coordinate every
+ // suggestion to it replies to, so an edit keeps it whatever the
+ // field says -- and the field is read-only to say so.
+ identifier = existing?.identifier ?: identifier,
+ title = title,
+ name = name,
+ description = description,
+ picture = picture,
+ domain = domain,
+ )
+
+ problems = schema.problems()
+ if (problems.isNotEmpty()) {
+ onInvalid.invoke()
+ return
+ }
+
+ if (existing != null && existing.schema == schema.normalized()) {
+ onNothingToPropose.invoke()
+ return
+ }
+
+ isActionPending.value = true
+
+ val template = GroupCuratedSchema.template(schema)
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val session = runCatching {
+ frostSigningRepository.proposeSigning(
+ localChatRoom = localChatRoom,
+ userPublicKey = activeUserPublicKey,
+ kind = template.kind,
+ tags = template.tags,
+ content = template.content,
+ )
+ }.onFailure { error ->
+ logger.e("Failed to propose a curated schema for $chatRoomId", error)
+ }.getOrNull()
+
+ viewModelScope.launch(Dispatchers.Main) {
+ if (session != null) {
+ onSuccess.invoke(session.id)
+ } else {
+ onFailure.invoke()
+ }
+ }
+
+ isActionPending.value = false
+ }
+ }
+
+ companion object {
+ private const val TAG = "EditGroupCuratedSchemaViewModel"
+
+ /**
+ * [input] as a pubkey the schema could name, or null.
+ *
+ * An npub -- with or without a `nostr:` in front, the way it is pasted
+ * from most clients -- or 64 hex characters in either case. Nothing else,
+ * and in particular not a NIP-05 address, since resolving one is a
+ * network round trip and this is a field on a form.
+ */
+ fun pubkeyOrNull(input: String): HexKey? {
+ val trimmed = input.trim().removePrefix("nostr:")
+
+ val hex = if (trimmed.startsWith("npub1", ignoreCase = true)) {
+ trimmed.bech32ToHexOrNull()
+ } else {
+ trimmed.lowercase()
+ }
+
+ return hex?.takeIf { candidate ->
+ candidate.length == 64 && candidate.all { it in '0'..'9' || it in 'a'..'f' }
+ }
+ }
+
+ fun factory(
+ activeUserPublicKey: HexKey,
+ chatRoomId: String,
+ relayHint: String?,
+ identifier: String?,
+ initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState =
+ EditGroupCuratedSchemaUIState.Loading,
+ chatRepository: ChatRepository,
+ frostSigningRepository: FrostSigningRepository
+ ): ViewModelProvider.Factory = viewModelFactory {
+ initializer {
+ EditGroupCuratedSchemaViewModel(
+ activeUserPublicKey = activeUserPublicKey,
+ chatRoomId = chatRoomId,
+ relayHint = relayHint,
+ identifier = identifier,
+ initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState,
+ chatRepository = chatRepository,
+ frostSigningRepository = frostSigningRepository
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
index e18535f4..601d993a 100755
--- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt
@@ -7,6 +7,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupNostrProfile
import press.mantra.compose.nostr.GroupPost
import press.mantra.compose.nostr.GroupRelayList
@@ -84,6 +85,17 @@ sealed interface ChatRoomDetailUIState {
* nothing, which is every group until somebody proposes one.
*/
val groupPosts: List = emptyList(),
+ /**
+ * The lists this group curates, as the schemas it signed for them, most
+ * recently signed first.
+ *
+ * Under the posts because a schema is the other thing a group publishes
+ * for strangers to answer: a post is the group speaking, a schema is the
+ * group asking -- for entries to a list it will then curate. Empty in a
+ * group that curates nothing, which is every group until somebody
+ * proposes a schema.
+ */
+ val groupCuratedSchemas: List = emptyList(),
/**
* Whether this device could sign a profile for the group.
*
diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt
new file mode 100644
index 00000000..d217e1b4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt
@@ -0,0 +1,42 @@
+package press.mantra.compose.ui.view.state
+
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.nostr.GroupCuratedSchema
+
+sealed interface EditGroupCuratedSchemaUIState {
+ data class Loaded(
+ val localChatRoom: LocalChatRoom,
+ /**
+ * The schema being edited, as the group last signed it, or null for a new
+ * one.
+ *
+ * Kept beside the working copy rather than replaced by it, because the
+ * button proposes only when something differs -- and "differs" needs
+ * both halves. Null also locks nothing: a new schema's identifier is the
+ * member's to choose, and an existing one's is not.
+ */
+ val existing: GroupCuratedSchema? = null,
+ /**
+ * The relays a new schema starts with: where the group has said it
+ * publishes, since that is where its list would be read.
+ *
+ * Only a new schema is seeded from these. An existing one has its own
+ * relays signed into it, and those are the ones an edit starts from.
+ */
+ val defaultRelays: List = emptyList(),
+ /**
+ * Whether this device holds a share of the group's key.
+ *
+ * False leaves the form writable and the button inert, for the same
+ * reason the other editors do: drafting a schema costs nothing, and only
+ * a share-holder can open the session that would sign it.
+ */
+ val canSign: Boolean = false,
+ ): EditGroupCuratedSchemaUIState
+
+ data class Error(
+ val message: String
+ ): EditGroupCuratedSchemaUIState
+
+ data object Loading: EditGroupCuratedSchemaUIState
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt
new file mode 100644
index 00000000..84751493
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt
@@ -0,0 +1,317 @@
+package press.mantra.compose.nostr
+
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.extensions.toHex
+import press.mantra.compose.managers.SharedKeyDerivation
+import press.mantra.compose.nostr.curated.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 com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
+import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
+import fr.acinq.bitcoin.ByteVector
+import fr.acinq.bitcoin.ByteVector32
+import fr.acinq.bitcoin.PrivateKey
+import fr.acinq.bitcoin.crypto.frost.Frost
+import fr.acinq.bitcoin.crypto.frost.IndividualNonce
+import fr.acinq.bitcoin.crypto.frost.KeyMaterial
+import fr.acinq.bitcoin.crypto.frost.SecretNonce
+import fr.acinq.bitcoin.crypto.frost.Session
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * What may be shown as a list the group curates.
+ *
+ * The reading gate is `GroupPostTest`'s -- the room has to be the author and the
+ * signature has to be the room's -- and it carries a weight of its own here: a
+ * schema read wrong would have strangers filling in a form under a group's name
+ * that the group never agreed to, and the group's key accepting entries against
+ * it. The third refusal is this reader's alone: a schema the room really signed
+ * but that no client could act on is not shown as one it curates.
+ *
+ * The ordering half is between the profile's and the posts': one per identifier,
+ * newest winning within it, because the kind is addressable and a group may
+ * curate several lists.
+ */
+class GroupCuratedSchemaTest {
+ private val participants = 3
+ private val threshold = 2
+
+ private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen(
+ thresholdSecretKey = PrivateKey(
+ ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1")
+ ),
+ nParticipants = participants,
+ threshold = threshold
+ )
+
+ /** Another group entirely, for the schemas signed by the wrong room. */
+ private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen(
+ thresholdSecretKey = PrivateKey(
+ ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3")
+ ),
+ nParticipants = participants,
+ threshold = threshold
+ )
+
+ private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH
+
+ private val chatRoomId =
+ SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path)
+
+ private val films = CuratedSchema(
+ identifier = "films",
+ title = "Film suggestion",
+ name = "Films worth translating",
+ description = "Films the group would subtitle, with a link to watch each one.",
+ visibility = CuratedVisibility.Public,
+ fields = listOf(
+ CuratedField.IDENTIFIER,
+ CuratedField.TITLE,
+ CuratedField(
+ name = "watchUrl",
+ type = CuratedFieldType.Url,
+ isRequired = true,
+ placeholder = "https://…",
+ label = "Where to watch",
+ config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true),
+ ),
+ ),
+ relays = listOf("wss://relay.example.com"),
+ )
+
+ @Test
+ fun `a schema the room signed is a list the group curates`() {
+ val curated = assertNotNull(
+ GroupCuratedSchema.of(signedEvent = signed(films), chatRoomId = chatRoomId)
+ )
+
+ assertEquals("films", curated.identifier)
+ assertEquals(chatRoomId, curated.publicKey)
+ assertEquals("31889:$chatRoomId:films", curated.coordinate())
+ assertEquals(films, curated.schema)
+ assertEquals("Films worth translating", curated.schema.displayName())
+ }
+
+ @Test
+ fun `a schema another group signed is not this group's`() {
+ // A real quorum and a real signature by a group with no standing to
+ // curate for this one. The row names this room because that is where it
+ // was filed; the author is what decides whose list it is.
+ val stranger = GroupSignedEvent.fromEvent(
+ event = schemaEvent(films, material = strangerMaterial),
+ chatRoomId = chatRoomId
+ )
+
+ assertNull(GroupCuratedSchema.of(signedEvent = stranger, chatRoomId = chatRoomId))
+ }
+
+ @Test
+ fun `a schema the room did not sign is not one`() {
+ // Authored by the room and signed by somebody else: the shape a member
+ // declaring a list in the group's name would produce, and the reason the
+ // reading verifies rather than trusting the author field.
+ assertNull(
+ GroupCuratedSchema.of(
+ signedEvent = signed(films, signer = strangerMaterial),
+ chatRoomId = chatRoomId
+ )
+ )
+
+ listOf("f".repeat(128), "not a signature", "").forEach { rubbish ->
+ assertNull(
+ GroupCuratedSchema.of(
+ signedEvent = signed(films, signature = rubbish),
+ chatRoomId = chatRoomId
+ ),
+ "a schema signed with \"$rubbish\" was accepted"
+ )
+ }
+ }
+
+ @Test
+ fun `an event of another kind is not a schema`() {
+ assertNull(
+ GroupCuratedSchema.of(
+ signedEvent = signed(films).copy(kind = CuratedSchemaEvent.SUGGESTION_KIND),
+ chatRoomId = chatRoomId
+ )
+ )
+ }
+
+ @Test
+ fun `a schema the room signed but no client could act on is refused`() {
+ // The room's real signature over a kind 31889 with no visibility. It is
+ // the group's statement and it is still not a list: a reader that showed
+ // it would be showing a form nobody may be allowed to fill in.
+ val unusable = signed(
+ films,
+ tags = CuratedSchemaEvent.template(films, createdAt = 1_700_000_000).tags
+ .filterNot { it[0] == "visibility" }
+ .toTypedArray()
+ )
+
+ assertNull(GroupCuratedSchema.of(signedEvent = unusable, chatRoomId = chatRoomId))
+ assertEquals(emptyList(), GroupCuratedSchema.newestPerListAmong(listOf(unusable), chatRoomId))
+ }
+
+ @Test
+ fun `the newest per identifier wins, and two identifiers are two lists`() {
+ val firstFilms = signed(films, createdAt = 1_700_000_000)
+ val editedFilms = signed(films.copy(description = "Films the group would subtitle."), createdAt = 1_700_000_900)
+ val books = signed(films.copy(identifier = "books", name = "Books worth translating"), createdAt = 1_700_000_300)
+
+ listOf(
+ listOf(firstFilms, editedFilms, books),
+ listOf(books, editedFilms, firstFilms),
+ listOf(editedFilms, books, firstFilms),
+ ).forEach { events ->
+ val lists = GroupCuratedSchema.newestPerListAmong(events, chatRoomId)
+
+ assertEquals(
+ listOf("films", "books"),
+ lists.map { it.identifier },
+ "the lists came back in the wrong order, or one of them went missing"
+ )
+ assertEquals(
+ "Films the group would subtitle.",
+ lists.first().schema.description,
+ "an edit did not replace the schema it revised"
+ )
+ }
+ }
+
+ @Test
+ fun `two versions signed in the same second resolve the same way on every device`() {
+ val one = signed(films.copy(description = "One."), createdAt = 1_700_000_000)
+ val other = signed(films.copy(description = "Other."), createdAt = 1_700_000_000)
+
+ val expected = GroupCuratedSchema.newestPerListAmong(listOf(one, other), chatRoomId).single()
+
+ assertEquals(
+ expected,
+ GroupCuratedSchema.newestPerListAmong(listOf(other, one), chatRoomId).single(),
+ "a tie on the timestamp was broken differently by the two orderings"
+ )
+ assertEquals(
+ if (one.id > other.id) "One." else "Other.",
+ expected.schema.description,
+ "the tie should break on the id, which is the only thing both devices share"
+ )
+ }
+
+ @Test
+ fun `a schema the editor built reads back as the group defined it`() {
+ val template = GroupCuratedSchema.template(films)
+
+ assertEquals(CuratedSchemaEvent.KIND, template.kind)
+ assertEquals(films.description, template.content)
+
+ val curated = assertNotNull(
+ GroupCuratedSchema.of(
+ signedEvent = signed(films, tags = template.tags, content = template.content),
+ chatRoomId = chatRoomId
+ )
+ )
+
+ assertEquals(films, curated.schema)
+ assertTrue(curated.schema.fields.first { it.name == "watchUrl" }.isRequired)
+ }
+
+ /** A schema as `FrostSigningManager.complete` files one. */
+ private fun signed(
+ schema: CuratedSchema,
+ tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
+ content: String = schema.description,
+ createdAt: Long = 1_700_000_000,
+ material: KeyMaterial = groupMaterial,
+ signer: KeyMaterial = material,
+ signature: String? = null
+ ): GroupSignedEvent = GroupSignedEvent.fromEvent(
+ event = schemaEvent(schema, tags, content, createdAt, material, signer, signature),
+ chatRoomId = chatRoomId,
+ derivationPath = SharedKeyDerivation.formatPath(path)
+ )
+
+ private fun schemaEvent(
+ schema: CuratedSchema,
+ tags: Array> = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
+ content: String = schema.description,
+ createdAt: Long = 1_700_000_000,
+ material: KeyMaterial = groupMaterial,
+ signer: KeyMaterial = material,
+ signature: String? = null
+ ): Event {
+ val groupPubKey = SharedKeyDerivation
+ .derive(material.thresholdPublicKey.value.toHex(), path)
+ .hex
+
+ val id = EventHasher.hashId(
+ pubKey = groupPubKey,
+ createdAt = createdAt,
+ kind = CuratedSchemaEvent.KIND,
+ tags = tags,
+ content = content
+ )
+
+ return Event(
+ id = id,
+ pubKey = groupPubKey,
+ createdAt = createdAt,
+ kind = CuratedSchemaEvent.KIND,
+ tags = tags,
+ content = content,
+ sig = signature ?: groupSignature(signer, id)
+ )
+ }
+
+ /**
+ * A real FROST signature by [material]'s quorum over [eventId], through the
+ * same shape `FrostSigningManager.advance` runs.
+ */
+ private fun groupSignature(material: KeyMaterial, eventId: String): String {
+ val cache = SharedKeyDerivation
+ .derive(material.thresholdPublicKey.value.toHex(), path)
+ .cache
+ val message = ByteVector(eventId.hexToByteArray())
+ val signerIds = listOf(0, 1)
+
+ val nonces = signerIds.map { signerId ->
+ SecretNonce.generate(
+ sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"),
+ secretShare = material.secretShares[signerId],
+ publicShare = material.publicShares[signerId],
+ tweakedThresholdPublicKey = cache.tweakedPublicKey,
+ message = message,
+ extraInput = null
+ )
+ }
+
+ val signingSession = Session.create(
+ aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!,
+ signerIds = signerIds.map { it.toUInt() },
+ signerPublicShares = signerIds.map { material.publicShares[it] },
+ nParticipants = participants,
+ threshold = threshold,
+ tweakCache = cache,
+ message = message
+ )
+
+ val partials = signerIds.mapIndexed { position, signerId ->
+ signingSession.sign(
+ nonces[position].first,
+ material.secretShares[signerId],
+ signerId.toUInt()
+ ).right!!
+ }
+
+ return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex()
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt
new file mode 100644
index 00000000..0270d58f
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt
@@ -0,0 +1,450 @@
+package press.mantra.compose.nostr.curated
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Kind 31889 as the NIP writes it, read and written back.
+ *
+ * The fixture is the bitcoin.mov schema from the NIP's own example, tag for tag,
+ * because the point of publishing a schema is that another client can drive its
+ * form from it -- so the one thing worth pinning is that what that site published
+ * reads here, and that what this app signs would read there.
+ *
+ * The other half is the rejection list. A schema is what suggestions are checked
+ * against, and a reader that repaired a broken one would be accepting entries
+ * against a form nobody signed. Every rule the NIP says a client MUST refuse on
+ * has a case here, and so does the one repair it says a client MUST make.
+ */
+class CuratedSchemaEventTest {
+ private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25"
+
+ private val description = "Fields for a bitcoin.mov entry: a Bitcoin movie, documentary, " +
+ "short, interview or series, with at least one link to watch or look it up."
+
+ /** The NIP's example, as published. */
+ private val bitcoinMov: Array> = arrayOf(
+ arrayOf("d", "bitcoin.mov"),
+ arrayOf("title", "bitcoin.mov suggestion"),
+ arrayOf("name", "bitcoin.mov"),
+ arrayOf("description", description),
+ arrayOf("k", "31888"),
+ arrayOf("visibility", "public"),
+ arrayOf("picture", "https://bitcoin.mov/icon.svg"),
+ arrayOf("domain", "bitcoin.mov"),
+ arrayOf("relay", "wss://ephemeral.mantra.press"),
+ arrayOf("field", "identifier", "token", "required", "the-rise-and-rise-of-bitcoin-2014", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"),
+ arrayOf("field", "title", "text", "required", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}"),
+ arrayOf("field", "year", "year", "optional", "2014", "Year", "{\"min\":1900,\"max\":2100}"),
+ arrayOf("field", "type", "enum", "required", "documentary", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"),
+ arrayOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"),
+ arrayOf("field", "imdbUrl", "url", "optional", "https://imdb.com/title/tt2821314", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"),
+ arrayOf("field", "image", "image", "optional", "https://…/poster.jpg", "Poster image URL (https)", "{\"max\":500}"),
+ arrayOf("field", "hashtags", "token", "optional", "bitcoin", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"),
+ arrayOf("field", "description", "longtext", "optional", "Why is this worth watching?", "Description / review", "{\"tag\":\"content\",\"max\":4000}"),
+ arrayOf("require-any", "watchUrl", "imdbUrl"),
+ )
+
+ @Test
+ fun `the bitcoin_mov schema reads as published`() {
+ val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
+
+ assertEquals("bitcoin.mov", schema.identifier)
+ assertEquals("bitcoin.mov suggestion", schema.title)
+ assertEquals("bitcoin.mov", schema.name)
+ assertEquals(description, schema.description)
+ assertEquals(CuratedVisibility.Public, schema.visibility)
+ assertEquals("https://bitcoin.mov/icon.svg", schema.picture)
+ assertEquals("bitcoin.mov", schema.domain)
+ assertEquals(31888, schema.suggestionKind)
+ assertEquals(listOf("wss://ephemeral.mantra.press"), schema.relays)
+ assertEquals(listOf(listOf("watchUrl", "imdbUrl")), schema.requireAny)
+ assertEquals(emptyList(), schema.suggesters)
+
+ assertEquals(
+ listOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"),
+ schema.fields.map { it.name }
+ )
+
+ // The positional parts and the config, on the field that uses most of them.
+ val watchUrl = schema.fields.first { it.name == "watchUrl" }
+ assertEquals(CuratedFieldType.Url, watchUrl.type)
+ assertFalse(watchUrl.isRequired)
+ assertEquals("https://youtube.com/watch?v=…", watchUrl.placeholder)
+ assertEquals("Watch / reference URL", watchUrl.label)
+ assertEquals("r", watchUrl.tag())
+ assertEquals("watch", watchUrl.config.marker)
+ assertEquals(500L, watchUrl.config.max)
+ assertEquals(true, watchUrl.config.repeat)
+
+ val type = schema.fields.first { it.name == "type" }
+ assertEquals(listOf("movie", "documentary", "short", "interview", "series", "other"), type.config.options)
+
+ val identifier = schema.fields.first { it.name == "identifier" }
+ assertEquals("d", identifier.tag())
+ assertEquals(true, identifier.config.derived)
+ assertTrue(identifier.isRequired)
+
+ // Derived fields are filled in by the client, so a form does not ask.
+ assertEquals(
+ listOf("title", "year", "type", "watchUrl", "imdbUrl", "image", "description"),
+ schema.formFields().map { it.name }
+ )
+ }
+
+ @Test
+ fun `the domain stands in for the name wherever the list is shown`() {
+ val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
+ assertEquals("bitcoin.mov", schema.displayName())
+
+ val withoutDomain = assertNotNull(
+ CuratedSchemaEvent.parse(event(tags = bitcoinMov.without("domain").replacing("name", "Bitcoin on screen")))
+ )
+ assertEquals("Bitcoin on screen", withoutDomain.displayName())
+ }
+
+ @Test
+ fun `a schema round trips through the template`() {
+ val read = assertNotNull(CuratedSchemaEvent.parse(event()))
+
+ val template = CuratedSchemaEvent.template(read, createdAt = 1735689600)
+ assertEquals(CuratedSchemaEvent.KIND, template.kind)
+ // The content mirrors the description, for clients that read content.
+ assertEquals(description, template.content)
+
+ val reread = assertNotNull(CuratedSchemaEvent.parse(event(tags = template.tags, content = template.content)))
+ assertEquals(read, reread)
+
+ // And the tags come out in the NIP's order, identity first.
+ assertEquals(
+ listOf("d", "title", "name", "description", "k", "visibility", "picture", "domain"),
+ template.tags.take(8).map { it[0] }
+ )
+ assertEquals(9, template.tags.count { it[0] == "field" })
+ assertEquals(listOf("require-any", "watchUrl", "imdbUrl"), template.tags.first { it[0] == "require-any" }.toList())
+ assertEquals(listOf("relay", "wss://ephemeral.mantra.press"), template.tags.last().toList())
+ }
+
+ @Test
+ fun `a field tag is written the way the NIP shows it`() {
+ val schema = assertNotNull(CuratedSchemaEvent.parse(event()))
+ val tag = schema.fields.first { it.name == "watchUrl" }.toTagArray()
+
+ assertEquals(
+ listOf("field", "watchUrl", "url", "optional", "https://youtube.com/watch?v=…", "Watch / reference URL",
+ "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"),
+ tag.toList()
+ )
+ }
+
+ @Test
+ fun `a config key this app does not know survives a round trip`() {
+ // Other clients legitimately add their own. Editing a field here must
+ // not strip what one of them meant by it.
+ val field = assertNotNull(
+ CuratedField.parse(arrayOf("field", "lang", "token", "optional", "en", "Language", "{\"max\":60,\"locale\":\"bcp47\"}"))
+ )
+
+ assertEquals(60L, field.config.max)
+ assertEquals("\"bcp47\"", field.config.others["locale"].toString())
+ assertEquals("{\"max\":60,\"locale\":\"bcp47\"}", field.config.toJson())
+ }
+
+ @Test
+ fun `a malformed config costs the field its constraints and not its life`() {
+ listOf("{not json", "[1,2]", "\"a string\"", "42", "").forEach { blob ->
+ val field = assertNotNull(
+ CuratedField.parse(arrayOf("field", "year", "year", "optional", "2014", "Year", blob)),
+ "the field was dropped over the config \"$blob\""
+ )
+ assertEquals(CuratedFieldConfig(), field.config, "the config \"$blob\" read as something")
+ }
+
+ // A known key of the wrong type is dropped rather than kept as something
+ // it is not; the rest of the blob is kept.
+ val field = assertNotNull(
+ CuratedField.parse(arrayOf("field", "year", "year", "optional", "", "", "{\"max\":\"lots\",\"min\":1900}"))
+ )
+ assertNull(field.config.max)
+ assertEquals(1900L, field.config.min)
+ }
+
+ @Test
+ fun `a field with an unknown type or no name is dropped and the rest are kept`() {
+ val schema = assertNotNull(
+ CuratedSchemaEvent.parse(
+ event(
+ tags = bitcoinMov.plus(
+ listOf(
+ arrayOf("field", "rating", "stars", "optional", "", "", "{}"),
+ arrayOf("field", " ", "text", "optional", "", "", "{}"),
+ arrayOf("field", "short"),
+ )
+ )
+ )
+ )
+ )
+
+ assertEquals(9, schema.fields.size)
+ }
+
+ @Test
+ fun `a field missing its trailing positions still reads`() {
+ val field = assertNotNull(CuratedField.parse(arrayOf("field", "director", "text")))
+
+ assertEquals("director", field.name)
+ assertFalse(field.isRequired)
+ assertEquals("", field.placeholder)
+ assertEquals("director", field.labelOrName())
+ assertEquals(CuratedFieldConfig(), field.config)
+ }
+
+ @Test
+ fun `a schema missing an identity tag is not usable`() {
+ mapOf(
+ "d" to CuratedSchemaProblem.IdentifierMissing,
+ "title" to CuratedSchemaProblem.TitleMissing,
+ "name" to CuratedSchemaProblem.NameMissing,
+ ).forEach { (tag, problem) ->
+ val tags = without(tag)
+ assertNull(CuratedSchemaEvent.parse(event(tags = tags)), "a schema without \"$tag\" was accepted")
+ assertEquals(listOf(problem), CuratedSchemaEvent.read(tags, description).problems())
+ }
+
+ // The description alone has a fallback: the content, which is where
+ // generic clients put it. Empty both and it is missing.
+ assertNull(CuratedSchemaEvent.parse(event(tags = without("description"), content = "")))
+ assertEquals(
+ listOf(CuratedSchemaProblem.DescriptionMissing),
+ CuratedSchemaEvent.read(without("description"), " ").problems()
+ )
+ }
+
+ @Test
+ fun `the description falls back to the content and the tag wins when both are there`() {
+ val fromContent = assertNotNull(
+ CuratedSchemaEvent.parse(event(tags = without("description"), content = " What the content says. "))
+ )
+ assertEquals("What the content says.", fromContent.description)
+
+ val fromTag = assertNotNull(CuratedSchemaEvent.parse(event(content = "Something else entirely")))
+ assertEquals(description, fromTag.description)
+ }
+
+ @Test
+ fun `visibility is never guessed`() {
+ assertNull(CuratedSchemaEvent.parse(event(tags = without("visibility"))))
+ assertEquals(
+ listOf(CuratedSchemaProblem.VisibilityMissing),
+ CuratedSchemaEvent.read(without("visibility"), description).problems()
+ )
+
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", "friends"))))
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("visibility", ""))))
+
+ // Case and whitespace are forgiven; a fourth word is not.
+ assertEquals(CuratedVisibility.Closed, CuratedSchemaEvent.parse(event(tags = replacing("visibility", " Closed ")))?.visibility)
+ assertEquals(CuratedVisibility.Private, CuratedSchemaEvent.parse(event(tags = replacing("visibility", "PRIVATE")))?.visibility)
+ }
+
+ @Test
+ fun `a schema with no fields is not usable`() {
+ val tags = bitcoinMov.filterNot { it[0] == "field" }.toTypedArray()
+
+ assertNull(CuratedSchemaEvent.parse(event(tags = tags)))
+ assertEquals(listOf(CuratedSchemaProblem.NoFields), CuratedSchemaEvent.read(tags, description).problems())
+
+ // Checked before the mandatory fields are put back, which is the NIP's
+ // rule: "no field tags" is about what the publisher said.
+ val onlyUnparseable = tags.plus(arrayOf("field", "rating", "stars", "optional", "", "", "{}"))
+ assertNull(CuratedSchemaEvent.parse(event(tags = onlyUnparseable)))
+ }
+
+ @Test
+ fun `a picture that is not https is not usable`() {
+ listOf("http://bitcoin.mov/icon.svg", "bitcoin.mov/icon.svg", "javascript:alert(1)", "https://").forEach { picture ->
+ assertNull(
+ CuratedSchemaEvent.parse(event(tags = replacing("picture", picture))),
+ "a schema with the picture \"$picture\" was accepted"
+ )
+ }
+
+ // Absent is fine; it is optional.
+ assertNull(CuratedSchemaEvent.parse(event(tags = without("picture")))?.picture)
+ assertTrue(CuratedSchemaEvent.read(without("picture"), description).isUsable())
+ }
+
+ @Test
+ fun `a domain that is not a hostname is not usable, and one that is gets normalized`() {
+ listOf("bitcoin", "localhost", "bit coin.mov", "-bitcoin.mov", "bitcoin_mov.example").forEach { domain ->
+ assertNull(
+ CuratedSchemaEvent.parse(event(tags = replacing("domain", domain))),
+ "a schema with the domain \"$domain\" was accepted"
+ )
+ }
+
+ // Lenient on input, strict on what is kept: the things people paste
+ // around a hostname come off, and what is left is what is checked.
+ assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "https://Bitcoin.MOV/")))?.domain)
+ assertEquals("bitcoin.mov", CuratedSchemaEvent.parse(event(tags = replacing("domain", "bitcoin.mov:8080/x y")))?.domain)
+ assertEquals("bitcoin.mov", CuratedSchemaEvent.normalizeDomain("@Bitcoin.mov:443/path."))
+ assertTrue(CuratedSchemaEvent.isDomain("sub.example.co.za"))
+ assertFalse(CuratedSchemaEvent.isDomain("example"))
+ }
+
+ @Test
+ fun `a relay that is not a relay is not usable`() {
+ listOf("https://ephemeral.mantra.press", "ephemeral.mantra.press", "wss://").forEach { relay ->
+ assertNull(
+ CuratedSchemaEvent.parse(event(tags = replacing("relay", relay))),
+ "a schema naming the relay \"$relay\" was accepted"
+ )
+ }
+
+ // The NIP allows ws://, whatever this app will let a group sign.
+ assertEquals(listOf("ws://localhost:7777"), CuratedSchemaEvent.parse(event(tags = replacing("relay", "ws://localhost:7777")))?.relays)
+
+ // Duplicates collapse; none at all is allowed.
+ assertEquals(
+ listOf("wss://ephemeral.mantra.press"),
+ CuratedSchemaEvent.parse(event(tags = bitcoinMov.plus(arrayOf("relay", "wss://ephemeral.mantra.press"))))?.relays
+ )
+ assertEquals(emptyList(), CuratedSchemaEvent.parse(event(tags = without("relay")))?.relays)
+ }
+
+ @Test
+ fun `an over-long identity tag is not usable`() {
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(101)))))
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("d", "d".repeat(101)))))
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("title", "t".repeat(201)))))
+ assertNull(CuratedSchemaEvent.parse(event(tags = replacing("description", "x".repeat(501)))))
+
+ assertNotNull(CuratedSchemaEvent.parse(event(tags = replacing("name", "n".repeat(100)))))
+ }
+
+ @Test
+ fun `the wrong kind is not a schema whatever its tags say`() {
+ assertNull(CuratedSchemaEvent.parse(event(kind = 31888)))
+ assertNull(CuratedSchemaEvent.parse(event(kind = 30023)))
+ }
+
+ @Test
+ fun `the identifier and the title are forced in whatever the schema says`() {
+ // A schema fetched from a hostile relay cannot talk a client into
+ // accepting untitled or unaddressable entries.
+ val onlyAYear = arrayOf(
+ arrayOf("d", "years"),
+ arrayOf("title", "A year"),
+ arrayOf("name", "Years"),
+ arrayOf("description", "Just years."),
+ arrayOf("visibility", "public"),
+ arrayOf("field", "year", "year", "optional", "", "", "{}"),
+ )
+
+ val schema = assertNotNull(CuratedSchemaEvent.parse(event(tags = onlyAYear)))
+
+ assertEquals(listOf("d", "title", "year"), schema.fields.map { it.tag() })
+ assertTrue(schema.fields[0].isRequired)
+ assertTrue(schema.fields[1].isRequired)
+ assertEquals(CuratedField.IDENTIFIER, schema.fields[0])
+ assertEquals(CuratedField.TITLE, schema.fields[1])
+
+ // And a title the publisher relaxed is made required again, in place.
+ val relaxed = replacing(
+ "field",
+ "title",
+ arrayOf("field", "title", "text", "optional", "The Rise and Rise of Bitcoin", "Title", "{\"max\":200}")
+ )
+ val repaired = assertNotNull(CuratedSchemaEvent.parse(event(tags = relaxed)))
+ assertEquals(9, repaired.fields.size)
+ assertTrue(repaired.fields.first { it.name == "title" }.isRequired)
+
+ // Running it again changes nothing.
+ assertEquals(repaired, repaired.normalized())
+ }
+
+ @Test
+ fun `a require-any naming fewer than two fields says nothing`() {
+ val schema = assertNotNull(
+ CuratedSchemaEvent.parse(
+ event(
+ tags = bitcoinMov.plus(
+ listOf(
+ arrayOf("require-any", "image"),
+ arrayOf("require-any"),
+ arrayOf("require-any", "year", " ", "type"),
+ )
+ )
+ )
+ )
+ )
+
+ assertEquals(listOf(listOf("watchUrl", "imdbUrl"), listOf("year", "type")), schema.requireAny)
+ }
+
+ @Test
+ fun `suggesters are the p tags that are pubkeys`() {
+ val other = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
+ val schema = assertNotNull(
+ CuratedSchemaEvent.parse(
+ event(
+ tags = replacing("visibility", "closed").plus(
+ listOf(
+ arrayOf("p", other),
+ arrayOf("p", other.uppercase()),
+ arrayOf("p", "not a pubkey"),
+ )
+ )
+ )
+ )
+ )
+
+ assertEquals(listOf(other), schema.suggesters)
+ }
+
+ @Test
+ fun `the coordinate is what a suggestion replies to`() {
+ assertEquals("31889:$curator:bitcoin.mov", CuratedSchemaEvent.coordinate(curator, "bitcoin.mov"))
+
+ // An identifier may itself hold colons, so the coordinate does too.
+ assertEquals("31889:$curator:imdb:tt2821314", CuratedSchemaEvent.coordinate(curator, "imdb:tt2821314"))
+ }
+
+ @Test
+ fun `an empty config is written as an empty object`() {
+ assertEquals("{}", CuratedFieldConfig().toJson())
+ assertEquals("{\"tag\":\"d\",\"max\":80,\"derived\":true}", CuratedField.IDENTIFIER.config.toJson())
+ }
+
+ private fun event(
+ tags: Array> = bitcoinMov,
+ content: String = description,
+ kind: Int = CuratedSchemaEvent.KIND,
+ ): Event = Event(
+ id = "a".repeat(64),
+ pubKey = curator,
+ createdAt = 1735689600,
+ kind = kind,
+ tags = tags,
+ content = content,
+ sig = "b".repeat(128),
+ )
+
+ private fun without(tag: String): Array> = bitcoinMov.without(tag)
+
+ private fun replacing(tag: String, value: String): Array> = bitcoinMov.replacing(tag, value)
+
+ private fun replacing(tag: String, name: String, with: Array): Array> =
+ bitcoinMov.map { if (it[0] == tag && it[1] == name) with else it }.toTypedArray()
+
+ private fun Array>.without(tag: String): Array> =
+ filter { it[0] != tag }.toTypedArray()
+
+ private fun Array>.replacing(tag: String, value: String): Array> =
+ map { if (it[0] == tag) arrayOf(tag, value) else it }.toTypedArray()
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt
new file mode 100644
index 00000000..342753a0
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt
@@ -0,0 +1,266 @@
+package press.mantra.compose.ui.composable
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.ComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotEnabled
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.getBoundsInRoot
+import androidx.compose.ui.test.onNodeWithContentDescription
+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
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.extensions.hexToNpubHrp
+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.ui.composable.widgets.ProvideSnackbarHost
+import press.mantra.compose.ui.theme.MantraTheme
+import press.mantra.compose.ui.view.model.EditGroupCuratedSchemaViewModel
+import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
+import kotlin.test.Test
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+
+/**
+ * What the schema editor puts in front of a member, in the two states it opens in.
+ *
+ * **A new list starts filled in.** The two fields every list has and the group's
+ * own relays are on the form before anything is typed, because they are what the
+ * group will sign whether or not the member adds to them -- and a form that
+ * showed an empty list and then signed two fields would be lying about what it
+ * proposed.
+ *
+ * **An existing list opens as an edit.** Its identifier is locked and the form
+ * says why, its rules show which fields they name, and the suggesters a closed
+ * list admits are there to be read and removed. The rows are what a member
+ * checks before proposing, and each is an intention a later change to the
+ * `LazyColumn` could drop without anything else noticing.
+ *
+ * **The sheet offers a type its own settings.** A url field gets "https only"
+ * and a year field does not, because a setting the event gives no meaning to
+ * would be written into the schema and mean nothing.
+ */
+@OptIn(ExperimentalTestApi::class)
+class EditGroupCuratedSchemaScreenJvmTest {
+
+ private val chatRoomId = "d4c3b2a1".repeat(8)
+
+ private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
+
+ private val films = CuratedSchema(
+ identifier = "films",
+ title = "Film suggestion",
+ name = "Films worth translating",
+ description = "Films the group would subtitle, with a link to watch each one.",
+ visibility = CuratedVisibility.Closed,
+ fields = listOf(
+ CuratedField.IDENTIFIER,
+ CuratedField.TITLE,
+ CuratedField(
+ name = "watchUrl",
+ type = CuratedFieldType.Url,
+ isRequired = false,
+ label = "Where to watch",
+ config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500),
+ ),
+ CuratedField(
+ name = "imdbUrl",
+ type = CuratedFieldType.Url,
+ isRequired = false,
+ label = "IMDb page",
+ config = CuratedFieldConfig(tag = "r", marker = "imdb", max = 500),
+ ),
+ ),
+ requireAny = listOf(listOf("watchUrl", "imdbUrl")),
+ suggesters = listOf(suggester),
+ relays = listOf("wss://relay.one.example"),
+ )
+
+ @Test
+ fun `a new list starts with the two fields every list has and the group's relays`() = render(
+ state(existing = null, defaultRelays = listOf("wss://relay.one.example/"))
+ ) {
+ // By their supporting line: "Identifier" is also the label over the
+ // identifier field above, and the row is the one that says what it is.
+ onNodeWithText("identifier · Token · Required · Derived").assertIsDisplayed()
+ onNodeWithText("title · Text · Required").assertIsDisplayed()
+ onNodeWithText("wss://relay.one.example/").assertIsDisplayed()
+
+ onNodeWithText("Public").assertIsSelected()
+ // A public list admits anyone, so there is no list of who it admits.
+ onNodeWithText("Suggesters").assertDoesNotExist()
+
+ onNodeWithText("Add field").assertIsDisplayed()
+ onNodeWithText("Add rule").assertIsDisplayed()
+ onNodeWithText("Propose schema").assertIsDisplayed()
+
+ // Nothing locks a new identifier: the member is choosing it.
+ onNodeWithText("The identifier names this list in every reply", substring = true)
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun `an existing list opens as an edit with its identifier locked`() = render(
+ state(existing = existing(films))
+ ) {
+ onNodeWithText("films").assertIsDisplayed()
+ onNodeWithText("The identifier names this list in every reply", substring = true)
+ .assertIsDisplayed()
+
+ onNodeWithText("Closed").assertIsSelected()
+
+ // Every field, in the order it will be written.
+ val identifier = onNodeWithText("identifier · Token · Required · Derived").getBoundsInRoot().top
+ val watch = onNodeWithText("Where to watch").getBoundsInRoot().top
+ val imdb = onNodeWithText("IMDb page").getBoundsInRoot().top
+ assertTrue(identifier < watch && watch < imdb, "the fields were drawn out of order")
+
+ // The rule names its fields, and the suggesters are readable as npubs.
+ onNodeWithText("At least one of").assertIsDisplayed()
+ onNodeWithText("Suggesters").assertIsDisplayed()
+ onNodeWithText(suggester.hexToNpubHrp()).assertIsDisplayed()
+ onNodeWithText("wss://relay.one.example").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a member holding no share of the key is told so and the button is inert`() = render(
+ state(existing = existing(films), canSign = false)
+ ) {
+ onNodeWithText("This group has no shared key, so it cannot sign a curated schema.", substring = true)
+ .assertIsDisplayed()
+ onNodeWithContentDescription("Propose schema").assertIsNotEnabled()
+ }
+
+ @Test
+ fun `the sheet offers a type its own settings and pins a mandatory field`() {
+ // A url field: https is a thing it can be told, options are not.
+ renderSheet(field = films.fields[2], index = 2, isMandatory = false) {
+ onNodeWithText("Edit field").assertIsDisplayed()
+ onNodeWithText("Url").assertIsSelected()
+ onNodeWithText("https only").assertIsDisplayed()
+ onNodeWithText("Options").assertDoesNotExist()
+ onNodeWithText("Minimum value").assertDoesNotExist()
+ onNodeWithText("Remove field").assertIsDisplayed()
+
+ // Changing the type changes what is offered.
+ onNodeWithText("One of a list").performClick()
+ onNodeWithText("Options").assertIsDisplayed()
+ onNodeWithText("https only").assertDoesNotExist()
+ }
+
+ // The identifier: required whatever the switch says, and not removable.
+ renderSheet(field = films.fields[0], index = 0, isMandatory = true) {
+ onNodeWithContentDescription("An entry must have this field").assertIsOn()
+ onNodeWithContentDescription("An entry must have this field").assertIsNotEnabled()
+ onNodeWithText("Remove field").assertDoesNotExist()
+ }
+ }
+
+ private fun existing(schema: CuratedSchema) = GroupCuratedSchema(
+ signedEvent = GroupSignedEvent(
+ id = "c".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = CuratedSchemaEvent.KIND,
+ tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
+ content = schema.description,
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ ),
+ schema = schema.normalized(),
+ )
+
+ private fun state(
+ existing: GroupCuratedSchema?,
+ defaultRelays: List = emptyList(),
+ canSign: Boolean = true,
+ ) = EditGroupCuratedSchemaUIState.Loaded(
+ localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = "a".repeat(64),
+ subject = "Translation room",
+ description = "A group translating hard books.",
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = "state"
+ )
+ ),
+ existing = existing,
+ defaultRelays = defaultRelays,
+ canSign = canSign,
+ )
+
+ /**
+ * The screen at a phone's width and a window tall enough to compose the whole
+ * of it, since a `LazyColumn` does not lay out what it would not show.
+ */
+ private fun render(
+ uiState: EditGroupCuratedSchemaUIState,
+ assertions: ComposeUiTest.() -> Unit
+ ) = runDesktopComposeUiTest(width = 400, height = 3000) {
+ setContent {
+ MantraTheme {
+ ProvideSnackbarHost {
+ Box(modifier = Modifier.fillMaxSize()) {
+ EditGroupCuratedSchemaScreen(
+ activeUserPublicKey = "a".repeat(64),
+ chatRoomId = chatRoomId,
+ relayHint = null,
+ identifier = (uiState as? EditGroupCuratedSchemaUIState.Loaded)
+ ?.existing?.identifier,
+ initialEditGroupCuratedSchemaUIState = uiState,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository =
+ FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ onNavigateToRouteAndPopUpInclusive = {}
+ )
+ }
+ }
+ }
+ }
+
+ assertions()
+ }
+
+ /**
+ * The sheet's content on its own: a bottom sheet is a popup with its own
+ * window, which a layout test cannot reach into.
+ */
+ private fun renderSheet(
+ field: CuratedField,
+ index: Int,
+ isMandatory: Boolean,
+ assertions: ComposeUiTest.() -> Unit
+ ) = runDesktopComposeUiTest(width = 400, height = 2000) {
+ setContent {
+ MantraTheme {
+ ProvideSnackbarHost {
+ Box(modifier = Modifier.fillMaxSize()) {
+ FieldEditorSheetContent(
+ edit = EditGroupCuratedSchemaViewModel.FieldEdit(index = index, field = field),
+ isMandatory = isMandatory,
+ onCommit = { null },
+ onRemove = {}
+ )
+ }
+ }
+ }
+ }
+
+ assertions()
+ }
+}
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
index fdf7711e..ce40ccec 100644
--- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt
@@ -9,17 +9,23 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.runDesktopComposeUiTest
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.GroupKeyState
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.GroupNostrProfile
import press.mantra.compose.nostr.GroupPost
import press.mantra.compose.nostr.GroupRelay
import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.nostr.GroupRelaySet
+import press.mantra.compose.nostr.curated.CuratedField
+import press.mantra.compose.nostr.curated.CuratedSchema
+import press.mantra.compose.nostr.curated.CuratedSchemaEvent
+import press.mantra.compose.nostr.curated.CuratedVisibility
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
@@ -189,6 +195,63 @@ class GroupNostrProfileSectionJvmTest {
onNodeWithText("Add post").assertIsDisplayed()
}
+ @Test
+ fun `the curated schemas sit under the posts and above the group's work`() = render(
+ state(
+ groupNostrProfile = profile,
+ relayLists = signedRelayLists(),
+ posts = listOf(post("Something the group said", 1_700_000_000)),
+ schemas = listOf(
+ schema("films", "Films worth translating", 1_700_000_900),
+ schema("books", "Books worth translating", 1_700_000_000)
+ )
+ )
+ ) {
+ val posts = onNodeWithText("Posts").getBoundsInRoot().top
+ val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top
+ val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top
+
+ assertTrue(posts < schemas, "the curated schemas climbed above the posts")
+ assertTrue(schemas < subgroups, "the curated schemas fell below the group's work")
+
+ // Most recently signed first, one card per list.
+ val newest = onNodeWithText("Films worth translating").getBoundsInRoot().top
+ val oldest = onNodeWithText("Books worth translating").getBoundsInRoot().top
+ assertTrue(newest < oldest, "the schemas were drawn oldest first")
+
+ // What an entry asks for, and who may suggest one.
+ onAllNodesWithText("Fields: Identifier, Title", substring = true).assertCountEquals(2)
+ onAllNodesWithText("Public · ", substring = true).assertCountEquals(2)
+ onNodeWithText("Add schema").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a group that curates nothing says so, and offers to add a schema`() = render(
+ state(groupNostrProfile = profile, relayLists = signedRelayLists())
+ ) {
+ onNodeWithText("Curated schemas").assertIsDisplayed()
+ onNodeWithText("This group hasn't published a curated schema yet.", substring = true)
+ .assertIsDisplayed()
+ onNodeWithText("Add schema").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a member holding no share of the key reads the schemas and is offered no editor`() =
+ render(
+ state(
+ groupNostrProfile = profile,
+ schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)),
+ canEditNostrProfile = false
+ )
+ ) {
+ // A schema exists to be read by people who were never in the room, so
+ // a member without a share reads it like anybody else and simply
+ // cannot propose a revision -- neither a new one nor an edit.
+ onNodeWithText("Films worth translating").assertIsDisplayed()
+ onNodeWithText("Add schema").assertDoesNotExist()
+ onNodeWithContentDescription("Edit schema").assertDoesNotExist()
+ }
+
@Test
fun `a group that has said nothing says so, and offers to say something`() = render(
state(groupNostrProfile = null)
@@ -218,12 +281,14 @@ class GroupNostrProfileSectionJvmTest {
.assertDoesNotExist()
onNodeWithText("Edit Nostr profile").assertDoesNotExist()
- // The relay lists and the posts go with it: all three describe a nostr
- // identity this room does not have.
+ // The relay lists, the posts and the schemas go with it: all four
+ // describe a nostr identity this room does not have.
onNodeWithText("Relays").assertDoesNotExist()
onNodeWithText("Edit relays").assertDoesNotExist()
onNodeWithText("Posts").assertDoesNotExist()
onNodeWithText("Add post").assertDoesNotExist()
+ onNodeWithText("Curated schemas").assertDoesNotExist()
+ onNodeWithText("Add schema").assertDoesNotExist()
// The rest of the screen is untouched, so the section's absence is an
// absence rather than a screen that failed to draw.
@@ -276,10 +341,37 @@ class GroupNostrProfileSectionJvmTest {
)
)
+ /**
+ * A list the group curates, built rather than read out of a signed event:
+ * the reading checks a real signature, and this test is about the screen.
+ */
+ private fun schema(identifier: String, name: String, createdAt: Long) = GroupCuratedSchema(
+ signedEvent = GroupSignedEvent(
+ id = createdAt.toString().padStart(64, 'c'),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = CuratedSchemaEvent.KIND,
+ tags = emptyArray(),
+ content = "",
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(createdAt)
+ ),
+ schema = CuratedSchema(
+ identifier = identifier,
+ title = "$name suggestion",
+ name = name,
+ description = "Things the group would translate, with a link to each.",
+ visibility = CuratedVisibility.Public,
+ fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE),
+ relays = listOf("wss://relay.one.example")
+ )
+ )
+
private fun state(
groupNostrProfile: GroupNostrProfile?,
relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId),
posts: List = emptyList(),
+ schemas: List = emptyList(),
canEditNostrProfile: Boolean = true,
mlsGroupState: String? = "state"
) = ChatRoomDetailUIState.Loaded(
@@ -309,6 +401,7 @@ class GroupNostrProfileSectionJvmTest {
groupNostrProfile = groupNostrProfile,
groupRelayLists = relayLists,
groupPosts = posts,
+ groupCuratedSchemas = schemas,
canEditNostrProfile = canEditNostrProfile
)
diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt
new file mode 100644
index 00000000..49f8943f
--- /dev/null
+++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt
@@ -0,0 +1,382 @@
+package press.mantra.compose.ui.view.model
+
+import press.mantra.compose.database.model.ChatRoom
+import press.mantra.compose.database.model.GroupSignedEvent
+import press.mantra.compose.database.model.intermdiate.LocalChatRoom
+import press.mantra.compose.extensions.hexToNpubHrp
+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.CuratedSchemaProblem
+import press.mantra.compose.nostr.curated.CuratedVisibility
+import press.mantra.compose.repository.ChatRepository
+import press.mantra.compose.repository.FrostSigningRepository
+import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+
+/**
+ * The decisions the schema editor makes that nothing else can check.
+ *
+ * **What a new list starts as.** The two fields every list has and the group's
+ * own relays, so that what the member sees is what the group will sign rather
+ * than what `CuratedSchema.normalized` will quietly add.
+ *
+ * **What cannot be taken away.** A field writing to `d` or `title` cannot be
+ * removed, because the event would only grow it back -- so the editor has to
+ * refuse rather than let a member believe they removed it.
+ *
+ * **What counts as a change.** The button proposes only when the schema differs
+ * from the one the group signed, and getting that wrong is quiet in both
+ * directions: too eager and every visit re-dates a decision the group did not
+ * make; too shy and an edit is dropped on a screen that said it had been sent.
+ */
+class EditGroupCuratedSchemaViewModelJvmTest {
+ private val chatRoomId = "d4c3b2a1".repeat(8)
+
+ private val films = CuratedSchema(
+ identifier = "films",
+ title = "Film suggestion",
+ name = "Films worth translating",
+ description = "Films the group would subtitle.",
+ visibility = CuratedVisibility.Closed,
+ fields = listOf(
+ CuratedField.IDENTIFIER,
+ CuratedField.TITLE,
+ CuratedField("watchUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "watch")),
+ CuratedField("imdbUrl", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(tag = "r", marker = "imdb")),
+ ),
+ requireAny = listOf(listOf("watchUrl", "imdbUrl")),
+ suggesters = listOf("e".repeat(64)),
+ relays = listOf("wss://relay.one.example"),
+ )
+
+ private fun viewModel(identifier: String? = null) = EditGroupCuratedSchemaViewModel(
+ chatRoomId = chatRoomId,
+ activeUserPublicKey = "a".repeat(64),
+ relayHint = null,
+ identifier = identifier,
+ initialEditGroupCuratedSchemaUIState = EditGroupCuratedSchemaUIState.Loading,
+ chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
+ frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
+ )
+
+ private val localChatRoom = LocalChatRoom(
+ chatRoom = ChatRoom(
+ id = chatRoomId,
+ userPublicKey = "a".repeat(64),
+ subject = "Translation room",
+ description = null,
+ initialGiftWrapPayloadId = "sdfaer",
+ mlsGroupState = "state"
+ )
+ )
+
+ /** The schema as the group signed it. Not verified here: the editor does not. */
+ private fun existing(schema: CuratedSchema = films) = GroupCuratedSchema(
+ signedEvent = GroupSignedEvent(
+ id = "c".repeat(64),
+ chatRoomId = chatRoomId,
+ publicKey = chatRoomId,
+ kind = CuratedSchemaEvent.KIND,
+ tags = CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000).tags,
+ content = schema.description,
+ signature = "f".repeat(128),
+ createdAt = Instant.fromEpochSeconds(1_700_000_000),
+ ),
+ schema = schema.normalized(),
+ )
+
+ private fun newSchema(defaultRelays: List = listOf("wss://relay.one.example")) =
+ EditGroupCuratedSchemaUIState.Loaded(
+ localChatRoom = localChatRoom,
+ existing = null,
+ defaultRelays = defaultRelays,
+ canSign = true,
+ )
+
+ private fun editing(schema: CuratedSchema = films) = EditGroupCuratedSchemaUIState.Loaded(
+ localChatRoom = localChatRoom,
+ existing = existing(schema),
+ defaultRelays = listOf("wss://relay.two.example"),
+ canSign = true,
+ )
+
+ @Test
+ fun `a new schema starts with the two fields every list has and the group's relays`() {
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema())
+
+ assertEquals(listOf("d", "title"), viewModel.fields.map { it.tag() })
+ assertTrue(viewModel.fields.all { it.isRequired })
+ assertEquals(CuratedVisibility.Public, viewModel.visibility)
+ assertEquals(listOf("wss://relay.one.example"), viewModel.relays)
+ assertEquals(emptyList(), viewModel.requireAny)
+ assertEquals(emptyList(), viewModel.suggesters)
+ }
+
+ @Test
+ fun `an existing schema starts as itself, and its relays are its own`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ assertEquals(films.fields, viewModel.fields.toList())
+ assertEquals(films.requireAny, viewModel.requireAny.toList())
+ assertEquals(films.suggesters, viewModel.suggesters.toList())
+ assertEquals(CuratedVisibility.Closed, viewModel.visibility)
+ // Not the defaults the state also carries: those are for a new list.
+ assertEquals(listOf("wss://relay.one.example"), viewModel.relays)
+ }
+
+ @Test
+ fun `seeding again leaves an edit alone`() {
+ // The effect that seeds is keyed on a state the view model can re-emit, so
+ // running twice has to be harmless -- otherwise a reload halfway through an
+ // edit throws the edit away.
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+ assertTrue(viewModel.addRelay("wss://relay.two.example"))
+
+ viewModel.seedWorkingCopy(editing())
+
+ // The added relay is normalized, which is how the group's other relay
+ // lists are written; the signed one is kept as the group wrote it.
+ assertEquals(listOf("wss://relay.one.example", "wss://relay.two.example/"), viewModel.relays)
+ }
+
+ @Test
+ fun `a relay already signed into the schema is a duplicate with or without the slash`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ assertFalse(viewModel.addRelay("wss://relay.one.example"))
+ assertFalse(viewModel.addRelay("wss://relay.one.example/"))
+ assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure)
+ assertEquals(listOf("wss://relay.one.example"), viewModel.relays)
+ }
+
+ @Test
+ fun `a mandatory field cannot be removed and another one can`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ assertTrue(viewModel.isMandatory(viewModel.fields[0]))
+ assertTrue(viewModel.isMandatory(viewModel.fields[1]))
+ assertFalse(viewModel.isMandatory(viewModel.fields[2]))
+
+ viewModel.removeField(0)
+ viewModel.removeField(1)
+ assertEquals(listOf("identifier", "title", "watchUrl", "imdbUrl"), viewModel.fields.map { it.name })
+
+ // A field that goes takes its place in the rules with it: a rule naming a
+ // field that no longer exists would silently never be satisfied.
+ viewModel.removeField(2)
+ assertEquals(listOf("identifier", "title", "imdbUrl"), viewModel.fields.map { it.name })
+ assertEquals(listOf(listOf("imdbUrl")), viewModel.requireAny.toList())
+ }
+
+ @Test
+ fun `renaming a field follows through to the rules that named it`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ viewModel.startEditingField(2)
+ assertNull(viewModel.commitField(viewModel.fields[2].copy(name = "watch")))
+
+ assertEquals(listOf("identifier", "title", "watch", "imdbUrl"), viewModel.fields.map { it.name })
+ assertEquals(listOf(listOf("watch", "imdbUrl")), viewModel.requireAny.toList())
+ assertNull(viewModel.editingField, "the sheet stayed open after a commit")
+ }
+
+ @Test
+ fun `a field name is one word and belongs to one field`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ viewModel.startAddingField()
+ val draft = viewModel.editingField!!.field
+
+ assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameMissing, viewModel.commitField(draft.copy(name = " ")))
+ assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameHasSpaces, viewModel.commitField(draft.copy(name = "watch url")))
+ assertEquals(EditGroupCuratedSchemaViewModel.FieldProblem.NameTaken, viewModel.commitField(draft.copy(name = "imdbUrl")))
+ assertEquals(4, viewModel.fields.size, "a refused field went in anyway")
+
+ assertNull(viewModel.commitField(draft.copy(name = " year ")))
+ assertEquals("year", viewModel.fields.last().name)
+
+ // Editing a field back to its own name is not taking it from anybody.
+ viewModel.startEditingField(4)
+ assertNull(viewModel.commitField(viewModel.fields[4].copy(label = "Year")))
+ }
+
+ @Test
+ fun `a rule with fewer than two names is dropped from the draft rather than refused`() {
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema())
+
+ viewModel.addRequireAnyGroup()
+ viewModel.toggleRequireAny(0, "title")
+ viewModel.addRequireAnyGroup()
+
+ val draft = viewModel.draft("d", "t", "n", "desc", "", "")
+
+ assertEquals(2, viewModel.requireAny.size, "the editor lost a rule being built")
+ assertEquals(emptyList(), draft.requireAny)
+ }
+
+ @Test
+ fun `a pubkey is an npub or hex and nothing else`() {
+ val hex = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860"
+ // Encoded rather than typed, so the checksum is right by construction.
+ val npub = hex.hexToNpubHrp()
+
+ assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(hex))
+ assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(" ${hex.uppercase()} "))
+ assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull(npub))
+ assertEquals(hex, EditGroupCuratedSchemaViewModel.pubkeyOrNull("nostr:$npub"))
+
+ listOf("", "npub1notanpub", hex.dropLast(1), "group@example.com", "nsec1" + npub.drop(5)).forEach {
+ assertNull(EditGroupCuratedSchemaViewModel.pubkeyOrNull(it), "\"$it\" was taken for a pubkey")
+ }
+
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema())
+ assertFalse(viewModel.addSuggester("group@example.com"))
+ assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.NotAPubkey, viewModel.addSuggesterFailure)
+ assertTrue(viewModel.addSuggester(npub))
+ assertFalse(viewModel.addSuggester(hex))
+ assertEquals(EditGroupCuratedSchemaViewModel.AddSuggesterFailure.AlreadyListed, viewModel.addSuggesterFailure)
+ assertEquals(listOf(hex), viewModel.suggesters)
+ }
+
+ @Test
+ fun `a url that is not a relay is refused and named`() {
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema(defaultRelays = emptyList()))
+
+ assertFalse(viewModel.addRelay("http://relay.one.example"))
+ assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.NotARelay, viewModel.addRelayFailure)
+ // The NIP allows ws://; what the group signs is held to the app's rule.
+ assertFalse(viewModel.addRelay("ws://relay.one.example"))
+
+ assertTrue(viewModel.addRelay("wss://relay.one.example"))
+ assertFalse(viewModel.addRelay("wss://relay.one.example"))
+ assertEquals(EditGroupCuratedSchemaViewModel.AddRelayFailure.AlreadyListed, viewModel.addRelayFailure)
+ }
+
+ @Test
+ fun `opening a schema and pressing the button proposes nothing`() {
+ // The case that has to be silent. A member opening the editor, reading it
+ // and pressing the button must not spend a quorum on an identical schema.
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ var outcome = ""
+ viewModel.proposeSchema(
+ localChatRoom = localChatRoom,
+ existing = existing(),
+ identifier = films.identifier,
+ title = films.title,
+ name = films.name,
+ description = films.description,
+ picture = "",
+ domain = "",
+ onSuccess = { outcome = "proposed" },
+ onInvalid = { outcome = "invalid" },
+ onNothingToPropose = { outcome = "nothing" },
+ onFailure = { outcome = "failed" },
+ )
+
+ assertEquals("nothing", outcome)
+ assertEquals(emptyList(), viewModel.problems)
+ }
+
+ @Test
+ fun `an edit keeps the identifier whatever the field says`() {
+ val viewModel = viewModel("films")
+ viewModel.seedWorkingCopy(editing())
+
+ var outcome = ""
+ viewModel.proposeSchema(
+ localChatRoom = localChatRoom,
+ existing = existing(),
+ // A different identifier would be a different list, not an edit.
+ identifier = "books",
+ title = films.title,
+ name = films.name,
+ description = films.description,
+ picture = "",
+ domain = "",
+ onSuccess = { outcome = "proposed" },
+ onInvalid = { outcome = "invalid" },
+ onNothingToPropose = { outcome = "nothing" },
+ onFailure = { outcome = "failed" },
+ )
+
+ assertEquals("nothing", outcome, "a changed identifier was taken as an edit")
+ }
+
+ @Test
+ fun `a schema a reader would refuse is refused here, and the reasons are kept`() {
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema())
+
+ var outcome = ""
+ viewModel.proposeSchema(
+ localChatRoom = localChatRoom,
+ existing = null,
+ identifier = " ",
+ title = "t".repeat(201),
+ name = "Films",
+ description = "",
+ picture = "http://example.com/x.png",
+ domain = "not a domain",
+ onSuccess = { outcome = "proposed" },
+ onInvalid = { outcome = "invalid" },
+ onNothingToPropose = { outcome = "nothing" },
+ onFailure = { outcome = "failed" },
+ )
+
+ assertEquals("invalid", outcome)
+ assertEquals(
+ listOf(
+ CuratedSchemaProblem.IdentifierMissing,
+ CuratedSchemaProblem.TitleTooLong,
+ CuratedSchemaProblem.DescriptionMissing,
+ CuratedSchemaProblem.PictureNotHttps,
+ CuratedSchemaProblem.DomainInvalid,
+ ),
+ viewModel.problems,
+ )
+ }
+
+ @Test
+ fun `the draft trims what was typed and normalizes the domain`() {
+ val viewModel = viewModel()
+ viewModel.seedWorkingCopy(newSchema())
+
+ val draft = viewModel.draft(
+ identifier = " films ",
+ title = " Film suggestion ",
+ name = " Films ",
+ description = " Films. ",
+ picture = " ",
+ domain = " https://Example.COM/ ",
+ )
+
+ assertEquals("films", draft.identifier)
+ assertEquals("Film suggestion", draft.title)
+ assertEquals("Films", draft.name)
+ assertEquals("Films.", draft.description)
+ assertNull(draft.picture)
+ assertEquals("example.com", draft.domain)
+ assertTrue(draft.isUsable())
+ }
+}