From f8e5d3610d2493e8db718904cfc0b81045c5f093 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 11 Sep 2026 15:57:41 +0200 Subject: [PATCH] feat(groups): the lists a group curates, and the one thing an edit may not change `334e6dd1` gave a group a way to speak in its own name. This is the way it asks: kind 31889, a *curated schema event*, signed by the room's own key -- the definition of a list anyone may suggest entries to and only that key may accept them into. The protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md` there, with `curated-schema-events.md` as the guided tour), and this is its first half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply to it and are not read or written here yet. The section sits under Posts and closes the nostr-identity block, above Subgroups. The profile says who the group is, the relay lists say where to find it, the posts are what it says there -- and a schema is the other thing it 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. Everything in that block is signed by that key and read by people who were never in the room, and the divider that used to close it after the posts now closes it after these. **The protocol is ported tag for tag from the reference module, and the fixture is the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags mean. The point of publishing a schema is that another client can drive its form from it, so the one property worth pinning is that what bitcoin.mov published reads here and what this app signs would read there. `CuratedSchemaEventTest` parses the bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`, the domain, the relay -- checks every field of it, and round-trips it through `template` back to an equal schema. The field tag's seventh position is a JSON blob, and a key this app does not know is kept in `CuratedFieldConfig.others` and written back, because other clients legitimately add their own and an edit here must not strip what one of them meant. **Rejected rather than repaired, with the one repair the NIP requires.** A schema is what suggestions are checked against, so a reader that patched a broken one would be accepting entries against a form nobody signed. `CuratedSchema.problems` is the NIP's rejection list and nothing more -- a missing identifier, title, name or description, an over-long one, a visibility that is absent or not one of the three words, no `field` tags at all, a picture that is not https, a domain that is not a hostname, a relay that is not a relay -- and it is the *same* list whether the schema was typed into the editor or read off a relay: the editor refuses to propose what a reader would refuse to show. The exception is `normalized`, which forces a field writing to `d` and one writing to `title` into place, both required, because that is the rule the NIP states so that no schema, wherever it came from, can talk a client into accepting untitled or unaddressable entries. The "no fields" check runs before that repair, on what the publisher said. **Visibility is never guessed, so it is nullable.** A list that does not say who may suggest to it is not one a client should act on, and the reference makes the same point by reporting a missing visibility as a violation rather than defaulting it. An enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null for a schema that does not say, `VisibilityMissing` is the problem it raises, and `template` writes no `visibility` tag for a null one -- which every reader, this one included, then refuses. The editor never produces one; only an event off a relay can be missing it. **One per identifier, newest wins.** The third ordering in this family. A profile is one replaceable event and the newest wins; posts are not replaceable and accumulate; a schema is addressable, so a group may curate several lists and each is its own coordinate `31889::`. `GroupCuratedSchema.newestPerListAmong` groups by identifier, keeps the newest within each -- an edit is a newer event under the same `d` -- and orders the lists most recently signed first, with the event id breaking every tie so two devices reading the same events in different orders agree. **An edit keeps the identifier, whatever the field says.** The identifier is the coordinate every suggestion to the list replies to. Changing it would not edit the list but start a second one with an empty queue and orphan the first's, so the identifier field is read-only on an edit, says why under itself, and `proposeSchema` takes it from the existing schema rather than from the field even so -- a screen not drawing something is not a guard. A new list is "Add schema" on the group's screen, which is why the section has both a per-card way in and a button: each schema is edited on its own. **A new list starts filled in, and with the group's own relays.** The two mandatory fields are put on the form before anything is typed, rather than left for `normalized` to add at signing time, because a form that showed an empty list and then signed two fields would be lying about what it proposed. The relays are seeded from the group's NIP-65 write relays -- where its canonical entries would be read from -- falling back to `GroupRelaySet.General.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 naming no relays is one whose suggestions could go anywhere, so the seed is worth getting right; the NIP says the same, and a client is allowed to fall back to its own list only when the schema names none. **What the editor lets a group sign is held to the app's rule, not the NIP's.** A `relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`, not this machine -- because the group is about to put a quorum's signature on it for strangers to publish to, which is exactly the argument that rule was written for. The duplicate check compares normalized forms, since a relay signed into an existing schema is kept as the group wrote it and the normalizer adds a trailing slash: the same host with and without one is one relay. The first version compared strings and would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it. Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub (with or without `nostr:`) or 64 hex characters and nothing else, and only offered when the visibility gives them something to be. **A field is edited on a sheet of its own, and a mandatory one cannot stop being one.** A field has a dozen settings, and thirteen of them inline would be a screen nobody could find anything on. The sheet offers the positional parts first -- name, label, placeholder, type, required -- and then only the config keys the chosen type gives a meaning to: `min` and a numeric `max` for the three numeric types, `options` for an enum, `https` for a url. A setting the event gives no meaning to would be written into the schema and mean nothing, so when the type changes away from one it is not kept. For a field writing to `d` or `title` the tag is pinned and the requirement is on and disabled, because `normalized` would only put the field back if the sheet let it be moved or relaxed, and offering a change the event will not carry is worse than not offering it. A field name is one word and belongs to one field, and a rename follows through to the `require-any` rules that named it, since a rule naming a field that no longer exists would silently never be satisfied. The rules themselves are a chip per field, selected for the ones in the rule; one with fewer than two names says nothing and is dropped from the draft rather than refused. **The transcript arm returns null rather than `unsupported`.** The opposite call to the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant, and a schema rumor from a member -- or one the room signed that no client could act on -- is identity plumbing, the position the kind:0 and relay-list arms are in. Parsed as well as verified, so the transcript and the group's screen agree about which events are lists: `GroupCuratedSchema.of` refuses the same event both places. The line names the list rather than describing the schema, because 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 fields are on the group's screen. `TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a `RitualNotice` and previewed like the other three. **The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for 31889, which the last three group features did not add for their kinds. A member deciding whether to sign this is agreeing to take suggestions from whoever the visibility admits and to be the one key that accepts them, and the event's content is only the description -- so "Event of kind 31889" over a sentence would leave them signing a list without being told its name, who may suggest to it, or how much an entry asks for. The summary is those three. **The sheet's switches carry their label as a description.** A screen reader otherwise announces "switch, off" beside a sentence it has already read past. It is also what the layout test reaches the switch by, which is how it was noticed. **The heading is "Curated schemas"**, not the kind's name. The audit enforces sentence case, and the sibling headings name the thing -- "Posts", "Relays" -- rather than the event. **And the caveat that bites hardest here of all: nothing has reached a relay.** The same limit `GroupPost` states, and a schema is the one statement whose *entire* purpose is to be replied to by strangers. Until group-signed events are published, a list defined here has no queue. The relays are signed into the schema ready for that, which is also why the NIP puts them in the event rather than in a config file. 51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the read, the round trip, the tag order, a config key this app does not know, a malformed blob costing a field its constraints and not its life, every rejection rule, the description falling back to content, the mandatory-field repair, and domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums through the same shape `FrostSigningManager.advance` runs -- the room's signature reads, a stranger's does not, a forged author does not, and a signed-but-unusable schema is refused -- and pins one-per-identifier ordering and the id tie-break. `EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock, the mandatory fields, renames following through to rules, the pubkey and relay rules, the normalized duplicate check, and the button proposing nothing for an untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in `GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a type its own settings, and the section's place on the screen, its empty state, a member with no share reading schemas they cannot edit, and its absence in a NIP-17 room. 1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@930d37c81e7bf67a0ec62d7cf69f596e57af3cb8 --- .../composeResources/values/strings.xml | 102 ++ .../compose/database/model/ChatMessage.kt | 44 + .../repository/DatabaseChatRepository.kt | 13 + .../compose/nostr/GroupCuratedSchema.kt | 107 ++ .../compose/nostr/curated/CuratedSchema.kt | 475 +++++ .../nostr/curated/CuratedSchemaEvent.kt | 206 +++ .../compose/repository/ChatRepository.kt | 16 + .../mantra/compose/text/ProposedEvent.kt | 17 + .../ui/composable/ChatRoomDetailScreen.kt | 179 ++ .../EditGroupCuratedSchemaScreen.kt | 1521 +++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 23 + .../routes/EditGroupCuratedSchemaRoute.kt | 20 + .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../model/EditGroupCuratedSchemaViewModel.kt | 544 ++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 12 + .../state/EditGroupCuratedSchemaUIState.kt | 42 + .../compose/nostr/GroupCuratedSchemaTest.kt | 317 ++++ .../nostr/curated/CuratedSchemaEventTest.kt | 450 +++++ .../EditGroupCuratedSchemaScreenJvmTest.kt | 266 +++ .../GroupNostrProfileSectionJvmTest.kt | 97 +- .../EditGroupCuratedSchemaViewModelJvmTest.kt | 382 +++++ 21 files changed, 4832 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchema.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/EditGroupCuratedSchemaRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/EditGroupCuratedSchemaUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedSchemaTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedSchemaEventTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/EditGroupCuratedSchemaScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/EditGroupCuratedSchemaViewModelJvmTest.kt 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()) + } +}