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