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
This commit is contained in:
@@ -559,4 +559,21 @@
|
||||
<string name="field_type_year">Year</string>
|
||||
<string name="field_type_duration">Duration in seconds</string>
|
||||
<string name="field_type_number">Number</string>
|
||||
<string name="propose_event">Propose event</string>
|
||||
<string name="paste_an_unsigned_event_for_the_group_to_sign">Paste an unsigned Nostr event for the group to sign. It is filed by kind: a profile (kind 0), a post (kind 1), a relay list, or a curated schema (kind 31889).</string>
|
||||
<string name="the_group_signs_an_event_so_it_takes_a_quorum">The group signs it, so it takes a quorum. The author is the group and the time is now, whatever the paste says about either.</string>
|
||||
<string name="this_group_has_no_shared_key_to_sign_an_event">This group has no shared key, so it cannot sign an event. Run a shared key ceremony first.</string>
|
||||
<string name="could_not_ask_the_group_to_sign_this_event">Couldn't ask the group to sign this event.</string>
|
||||
<string name="unsigned_event">Unsigned event</string>
|
||||
<string name="eg_kind_1_tags_content">{"kind": 1, "tags": [], "content": "…"}</string>
|
||||
<string name="what_the_group_would_sign">What the group would sign</string>
|
||||
<string name="kind_n">Kind %1$s</string>
|
||||
<string name="that_is_not_json">That isn't JSON.</string>
|
||||
<string name="an_event_is_a_json_object">An event is a JSON object with a kind, tags and content.</string>
|
||||
<string name="an_event_needs_a_kind">An event needs a kind.</string>
|
||||
<string name="kind_n_has_nowhere_to_go_on_this_screen">Kind %1$s has nowhere to go on the group's screen. It can file kinds %2$s.</string>
|
||||
<string name="tags_must_be_a_list_of_lists_of_strings">Tags must be a list of lists of strings.</string>
|
||||
<string name="content_must_be_text">Content must be text.</string>
|
||||
<string name="the_content_is_not_a_profile">The content isn't a profile. A kind 0 carries a JSON object of profile fields.</string>
|
||||
<string name="this_schema_cannot_be_signed_as_it_is">This schema can't be signed as it is:</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user