Files
mantra-kmp/composeApp
Kgothatso Ngako 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
..