A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`, where those are one value -- so every group in this app has had a nostr identity from the moment it had a key, and has never had a way to say anything about it. This adds the two statements that identity is made of: a kind:0 saying who the group is, and the four relay lists saying where it can be found. Both are signed by the group's quorum like everything else it says. Two sections on the group's detail screen, between the signing key and the library, and two editors behind them. Neither editor saves: what leaves them is a `FrostSigningSession`, and the profile changes on every member's device at once when enough members sign, or not at all. **A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.** `Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a `NostrEvent`: no member sent it, it never travelled the wire as itself, and the outbound pipeline re-authors rumors as their sender and would strip the group's signature off -- which is exactly the position `GroupSignedEvent` exists for. So `GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are readings rather than rows. Nothing new is stored: `FrostSigningManager.complete` already files every signed event before it applies one, so both readers had their data before this change and neither adds a table, a migration or a DAO method beyond the two existing kind-scoped queries. **Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust model.** The author has to be the room, the id has to be the hash of the fields beside it, and the signature has to verify -- checkable from the row alone, with no ceremony or key state to consult. A kind:0 that merely arrived in the room is not the group's profile; a relay list filed against the room by another group is not the group's relay list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's signature, a forged author and three kinds of rubbish in the signature field are all tested against the code that actually verifies rather than against a mock of it. **The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft did not.** Every kind the group signs needs an arm there or it falls through to `unsupported` and puts raw JSON in the transcript -- so a profile update would have appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot tell them apart: a completed signing session, where the author is the room by construction, and an arriving inner event, where it is whatever the sender wrote. A rumor carries an empty signature and any pubkey its sender likes, so the relay arm's original author-only check would have let one member write "the group signed its general relay list" into the room's transcript with no group involved. It now builds a `GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list passing through the room fails the author half and gets no line at all, which is right -- it is theirs, not the group's. **`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types. The profile and the relay lists are the same kind of statement and the transcript does the same thing with both -- a `RitualNotice`, answered and settled, because by the time the line exists a quorum has signed and there is nothing left to do about it. A type missing from that set renders as a chat bubble, silently, looking exactly like a member having said "The group is now called Translation collective"; that is why it is a set with two members rather than two checks. **Relay lists: four sets, and the two that were left out are the interesting part.** General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked (NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not here even though this app writes one for every person: a key package is a device's offer to be added to an MLS group, and a group has no device and joins nothing, so a group advertising where its key packages live would point at an address that is permanently empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason below. **A group signs and cannot decrypt, so every list is written in public tags.** NIP-51 puts a relay list's entries in the encrypted half by default, and a blocked list especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to the author's own key and there is no ECDH for a FROST threshold key here. That rules out 10012 entirely -- its list is only ever private -- and it means the group's blocked list is public where a person's client would keep it secret. Said in the code, and said on the Blocked tab where somebody is about to act on it. **The editor proposes every list that changed at once, which is the deliberate departure from the interface it copies.** Wisp publishes the tab in front of you, because a person signs for themselves and there is nothing to coordinate. Here each signature costs a quorum's attention, and four sessions for one sitting at one screen would ask for it four times over what is plainly one decision. `changedSets` is what keeps that honest, and it is the piece with a quiet failure on both sides: too eager and every visit re-signs four untouched lists, dating a decision the group did not make; too shy and an edit is dropped on a screen that said it had been sent. It compares in order, because a relay list is written in the order it is read back and a member who moved a relay to the front meant to; it counts a seeded first-time list as a change, so a group whose editor filled itself in will actually send it; and it counts an empty unagreed set as no change, so opening Blocked and pressing the button does not put a quorum's signature over silence. Nine cases in `EditGroupRelaysViewModelJvmTest`. **A relay that is neither read nor write is a state NIP-65 cannot express**, so it must not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read` for read-only and `write` for write-only, and has nothing for neither -- what neither would mean is that the relay is not in the list, and removing it is the row's own button. The toggles refuse to turn off the last marker and `template` drops such a relay as a second line of defence, because a bare tag written for it would advertise it as *both*, which is the opposite of what was asked. The round trip is tested for all three markers at once: the ordinary case is the dangerous one, since "both" is encoded by the third element being *absent*, so a reader that dropped it entirely would look correct for a both-ways relay and silently promote every read-only one. **`ephemeral.mantra.press` is what a first-time group is seeded with**, for General, Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish, finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to talk to somebody that nobody in the group chose. **A profile edit builds on the group's last one; a relay list does not.** `template` for the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead of being dropped by it. Using `createNew` there would compile, pass any test that only looked at the six fields on the form, and quietly wipe the rest every time somebody fixed a typo in the group's name. A relay list is the opposite: it is one list, replacing it is the whole point of signing a new one, and carrying a tag forward would make removal impossible. Both are tested for the behaviour that would be silent if wrong. **The name is written to both `name` and `display_name`.** They are the two fields readers pick a name out of and they disagree at their peril: a group whose `display_name` came from some other tool and whose `name` was edited here would keep answering to the old one in every client preferring `display_name` -- an edit that visibly does not take. One field on the form, both keys in the event. **Blank clears, and that is the only way to unsay something a group has signed.** `MetadataEvent`'s rule for these arguments is that an empty string removes the key and null leaves it alone, so the form sends what its fields hold. Said on the screen, because a member emptying a field is entitled to know whether it will be ignored. **Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather than a key it can sign as, so it has no nostr identity and never will; an empty section there would promise something that is not coming. Every other room shows the sections and gates only the two edit buttons on holding a share of the key -- the profile and the relay lists are worth reading whoever is looking, and only a share-holder can open a session about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry points and the subgroup one, since it walks the room's ceremonies looking for a share and asking three times would do the same walk three times for one answer. **"Never agreed" and "agreed empty" are different states and the summary says which.** A set the group has never signed reads "not set yet"; one it signed empty reads "no relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and `GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so the screen has a row per set either way. **The editor's working copy is seeded from the screen, not from the loader**, and that is a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an empty editor while the app worked fine, which is the shape of bug that survives review because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy` is idempotent and fills only missing keys, so the effect that calls it can re-run without throwing away typing. **`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since a group has no `Profile` row to hand it and the picture and the name were all it ever wanted from one. The two existing overloads delegate to it, so there is one avatar rather than a second one for groups. **What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete` applies signed events locally and puts nothing on the wire, for the reason its comment gives. So the profile and the lists are visible to members and to nobody else, and the relay lists say where the group's work *should* go without anything yet sending it there. Publishing would mean a `NostrEvent` row for an event no member authored, which is an architectural decision and belongs in its own change. Said at the top of both readers so the next reader does not assume otherwise. **And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds that a validly signed old one replayed by whoever kept a copy is a statement nobody can refuse. These five kinds are in the same position, so a member who joins after a profile is signed will not be handed it in their catch-up. Adding them is a decision with its own trade and is not made here. 48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape `\'`, so the aapt spelling would render the backslash. 1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`, 41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@4c0ed0c1ce
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…