`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:<room>:<d>`. `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 <noreply@anthropic.com> Pulled-From: curated/curated@930d37c81e
This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).
-
/composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:
- commonMain is for code that’s common for all targets.
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
-
/iosApp contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
Build and Run Android Application
To build and run the development version of the Android app, use the run configuration from the run widget in your IDE’s toolbar or build it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:assembleDebug - on Windows
.\gradlew.bat :composeApp:assembleDebug
Build and Run Desktop (JVM) Application
To build and run the development version of the desktop app, use the run configuration from the run widget in your IDE’s toolbar or run it directly from the terminal:
- on macOS/Linux
./gradlew :composeApp:run - on Windows
.\gradlew.bat :composeApp:run
Build and Run iOS Application
To build and run the development version of the iOS app, use the run configuration from the run widget in your IDE’s toolbar or open the /iosApp directory in Xcode and run it from there.
Learn more about Kotlin Multiplatform…