Files
mantra-kmp/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupEventProposal.kt
T
Kgothatso NgakoandClaude Opus 5 26de13b010 feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says
The group's identity block has a typed editor for each thing the group signs
about itself -- the profile form, the post composer, the relay editor, the schema
editor -- and each builds one kind of event. This is the untyped way in: an
unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to
the quorum like anything else, and filed by kind once it is signed. A pasted kind
0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind
10002 the general relay list. The entry is "Propose event", last in the identity
block, behind the same share-holder gate as every button in it and absent from a
NIP-17 room like the block itself.

The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov
repo prints exactly the kind 31889 event it would publish, id, pubkey, signature
and all; until now the only way to get that list under a group's key was to
retype it field by field into the editor. Now it is pasted, checked, and signed.

**Where the event lands is decided by nothing in this change, and that is the
design.** `FrostSigningManager.complete` files every signed event as a
`GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists
and schemas off those rows by kind, each reader verifying the room's signature
for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the
profile form does, because by the time either is signed there is no telling them
apart. A second path -- a switch on kind that wrote the pasted event into the
right place -- would have been a second reading of the same rows, and two
readings of one signature drift. So the persistence half of this feature is a
test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1,
kind 31889 and kind 10002 each through a real FROST quorum signature, in the
shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`,
`GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out,
under the group's key and coordinate rather than the pasted one.

**What the paste says about its author and its time is ignored, and the screen
says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`:
what goes to `proposeSigning` is the kind, the tags and the content, and the
session resolves the room's key, hashes the id over it and stamps the time it
opens at -- which is what every typed editor does too. `created_at` in particular
had to go. Every kind here is replaceable or addressable, so a pasted timestamp
older than the group's last event would make the new one *lose* to the old on
every reader and the proposal would look as though it had done nothing. The
explanatory line under the byline names both facts, because a paste with a
`pubkey` in it is the one case where a member could reasonably expect otherwise.

**Only kinds the screen has a place for, and only events that place would
accept -- checked before the quorum is asked, not after.** A signature is the most
expensive thing this app does, and the readers refuse rather than repair: a kind 0
whose content is not a profile, a blank kind 1, a kind 31889 with no visibility
would each be signed, filed and shown nowhere, with nothing to tell the member
their quorum was spent on nothing. So `GroupEventProposal.read` makes every check
a reader makes, on the paste, and refuses with the reader's own reason -- the
schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the
schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of
kinds with a section on the group's screen: 0, 1, the four relay lists and 31889.
Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate
or a key state event has invariants a paste cannot be trusted to meet and a place
in the room's life that a form is not, and the nip30303 kinds have editors of
their own. A long-form article is a perfectly good event the group could sign; it
is refused because the line is "has somewhere to land", and the refusal names the
kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing
groups are entitled to do, and an empty relay list is accepted, since it is a
withdrawal the relays card shows as "no relays" rather than as nothing -- the same
two lines `GroupNostrProfile.of` and `GroupRelayList` already draw.

**The preview is the signing screen's own words.** Above the paste is what the
group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1
field" -- read live on every change, since a paste is a few kilobytes and one JSON
parse is cheaper than a debounce that would hide the answer for a beat. It is
`ProposedEvent.summarize` on the reading, not a description of this screen's own,
so that what a member reads before proposing is word for word what every other
member reads before signing. Two descriptions of one event would drift.

**And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay
lists.** The three features before this one added none for their kinds, so a
member asked to sign the group's profile has been shown "Event of kind 0" over a
line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A
profile is now said by its name and what it says about itself, with "An empty
profile" and "Unreadable profile" kept apart because a signer should know which; a
post by its words, the same call the translated-passage arm makes; a relay list
by which of the four it is and its hosts, or "No relays" for a withdrawal.
`ProposedEventTest` is new and pins all of them, the schema arm from the last
commit included, and that an unrecognised kind keeps its number -- refusing to
describe an event is better than describing it wrongly.

**The paste stays on every refusal.** A refused reading is drawn under the field
and the button, pressed anyway, is answered rather than ignored; a failed proposal
is a snackbar over the same paste. The JSON is the thing worth keeping, and a
member who pasted two kilobytes of schema should not have to find them again
because a visibility tag was missing. The field is set in a monospace face for
the same reason: it is JSON, and a bracket that does not line up is what a member
is looking for in it. `isActionPending` guards a double press, because a pasted
kind 1 accumulates like any other post and a second session over the same paste
is a second post on the record forever.

**The byline is the group's**, as on the post composer and for its reason: whatever
is pasted goes out in the group's name, and a paste naming some other `pubkey`
goes out in the group's name anyway.

23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and
what is dropped, junk named as junk, the accepted set, an unreadable profile
against an empty one, a blank post, a schema refused with the reader's reasons, a
relay list with or without relays -- and the four signed chains that are this
commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer.
`ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview,
the two refusals and the inert button for a member with no share, and two more
cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block
and take it away from a member with no share and from a NIP-17 room.

1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 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@392b90b8d5
2026-09-13 11:58:00 +02:00

189 lines
7.8 KiB
Kotlin

package press.mantra.compose.nostr
import press.mantra.compose.network.serialization.CommonJson
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.curated.CuratedSchemaProblem
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
/**
* An unsigned event a member pasted for the group to sign, read and checked
* before a quorum is asked for it.
*
* The typed editors -- the profile form, the post composer, the relay and
* schema editors -- each build one kind of event and know what it needs. This
* is the untyped way in: the event as JSON, the way another tool prints it
* (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event
* it would publish), pasted whole. What comes out is the same thing the editors
* produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`,
* and it is filed the same way when the quorum signs: `FrostSigningManager.complete`
* records it as a `GroupSignedEvent` and the group's screen reads its profile,
* posts, relay lists and schemas straight off those by kind.
*
* ### What is ignored
*
* `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the
* session resolves the room's key and hashes the id over it -- and the time is
* when the session opens, which is what every typed editor does too. A pasted
* `created_at` in particular has to go: the kinds here are replaceable or
* addressable, so a stale one would make the new event *older* than the last and
* the proposal would look as though it did nothing. The screen says so.
*
* ### What is refused, and why before rather than after
*
* Only kinds the group's screen has a place for, and only events that place
* would accept. A signature is the most expensive thing this app does, and the
* readers refuse rather than repair: a kind 0 whose content is not a profile,
* a blank post, a schema with no visibility would each be signed, filed and
* shown nowhere, with nothing to tell the member their quorum was spent on
* nothing. So every check a reader makes is made here first, on the paste.
*/
object GroupEventProposal {
/**
* The kinds a pasted event may be: exactly the ones the group's screen reads.
*
* Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup
* certificate or a key state event has invariants a paste cannot be trusted
* to meet and a place in the room's life that a form is not, and the
* nip30303 kinds have editors of their own. The line is "has a section on the
* group's screen", because that is where a member goes to see it landed.
*/
val ACCEPTED_KINDS: Set<Kind> =
setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS
sealed interface Reading
/** An event the group could sign and the screen could show. */
class Ready(
val kind: Kind,
val tags: Array<Array<String>>,
val content: String,
) : Reading {
/**
* The event as the signing screen will describe it, for a preview before
* the button is pressed. The id, author and signature are placeholders:
* `ProposedEvent.summarize` reads none of them.
*/
fun preview(): Event = Event(
id = "",
pubKey = "",
createdAt = 0,
kind = kind,
tags = tags,
content = content,
sig = "",
)
}
/** Why the paste cannot be proposed as it is. */
sealed interface Refused : Reading {
data object NotJson : Refused
/** JSON, but an array, a string, a number -- anything but an object. */
data object NotAnObject : Refused
data object NoKind : Refused
data class KindNotAccepted(val kind: Kind) : Refused
/** `tags` present but not a list of lists of strings. */
data object TagsMalformed : Refused
/** `content` present but not a string. */
data object ContentNotText : Refused
/** A kind 0 whose content will not parse as a profile. */
data object ProfileUnreadable : Refused
/** A kind 1 with nothing to say, which the posts reader would drop. */
data object PostBlank : Refused
/** A kind 31889 the schema reader would refuse, with its reasons. */
data class SchemaUnusable(val problems: List<CuratedSchemaProblem>) : Refused
}
private const val KEY_KIND = "kind"
private const val KEY_TAGS = "tags"
private const val KEY_CONTENT = "content"
/** The paste as something the group could sign, or the reason it is not. */
fun read(json: String): Reading {
val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull()
?: return Refused.NotJson
val obj = element as? JsonObject ?: return Refused.NotAnObject
// A number or a numeric string: relays write the former and a hand
// types either, and there is nothing to be gained by refusing "1".
val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind
if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind)
val tags = when (val raw = obj[KEY_TAGS]) {
null -> emptyArray()
else -> readTags(raw) ?: return Refused.TagsMalformed
}
val content = when (val raw = obj[KEY_CONTENT]) {
null -> ""
else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content
?: return Refused.ContentNotText
}
return when (kind) {
// An empty kind 0 is a profile the group has wiped, which it is
// entitled to do; content that is not JSON is not a profile at all.
// The same line `GroupNostrProfile.of` draws.
MetadataEvent.KIND -> if (metadataOf(tags, content) == null) {
Refused.ProfileUnreadable
} else {
Ready(kind, tags, content)
}
// The line `AddGroupPostViewModel.proposePost` draws, for the reason
// it gives: a blank post cannot be told from a broken one and, kind:1
// not being replaceable, cannot be un-said.
TextNoteEvent.KIND -> if (content.isBlank()) {
Refused.PostBlank
} else {
Ready(kind, tags, content)
}
CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems ->
if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems)
}
// A relay list with no relay in it is a list the group has withdrawn,
// which the relays card shows as "no relays" rather than as nothing.
else -> Ready(kind, tags, content)
}
}
private fun readTags(raw: JsonElement): Array<Array<String>>? {
val outer = raw as? JsonArray ?: return null
return outer.map { tag ->
val inner = tag as? JsonArray ?: return null
inner.map { value ->
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null
}.toTypedArray()
}.toTypedArray()
}
private fun metadataOf(tags: Array<Array<String>>, content: String) = MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = tags,
content = content,
sig = "",
).contactMetaData()
}