`4c0ed0c1` gave a group a name and an address on nostr and nothing to say from either. This is the third statement that identity is made of: kind:1s authored by the room's own key, listed under the relay lists on the group's screen, with a composer behind "Add post" that proposes the next one to the quorum. The section sits under Relays and above Library on purpose. The profile says who the group is, the relay lists say where to find it, and these are what a reader would find there -- so they finish the identity block, and the library below them is the group's *work* rather than its voice. **A post is the group speaking; the transcript is a member speaking.** Those are different things and the room already had the second. A room's messages are `ChatEvent` kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any reader can check the group said it and that no single member could have made it say so. The composer's byline is the whole of that design: every other composer in this app puts the writer's name over the draft because the writer is the author, and doing that here would have a member writing what looks like their own note and finding the group had said it. The byline is the group's profile picture and name, shown before a word is typed. **The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is dangerous in the opposite direction to the last two.** Every kind the group signs needs an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not like kind:0 or a relay list: those are identity plumbing nobody sends into a room on purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has rendered as an `unsupported` event since long before any of this existed. So this arm verifies, and on failure **falls through to `unsupported` rather than dropping the event** -- which required naming that branch as a local `fun unsupported()` so one arm can reach it deliberately instead of only falling off the end of the `when`. Getting this wrong would have been this change quietly deleting messages it has no business touching, and it would have looked like nothing at all. **Verified, not merely attributed** -- the same rule the other two arms ended up at. A rumor carries an empty signature and any pubkey its sender likes, so an author check alone would let one member write "the group posted in its own name" into the room's own transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says. **Posts accumulate; everything else in this feature replaces.** kind:1 is not replaceable, so a second post stands beside the first rather than superseding it, and every "newest wins" the profile and the relay lists are built on is wrong for these. `newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts survive all three orderings a query could hand them over in -- a reading that kept only the newest would silently delete a group's entire history the first time it posted twice, and would look like a feature until somebody noticed. **Blank posts are refused twice.** The composer will not propose one, because a quorum signing an empty note puts a post on the record that renders as nothing, cannot be told from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And `newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from outside this app is in exactly the same position. **A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag `TextNoteEvent.build` can add describes a relationship to something else that a group's first way of posting does not have and should not guess at. The mark is for an event that arrived some other way: drawing a reply as an announcement would put half a conversation on a group's page with nothing saying it was half. **The card shows the whole note.** A post is short by construction and it is the one thing on that screen which exists to be *read*; truncating it would mean tapping through to see a paragraph. The composer grows into the screen for the same reason -- a three-line box that hides what was written two sentences ago is a box nobody proofreads in. **No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both `NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off `GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete` already files every signed event before applying it, so no table, no migration, and one more kind-scoped query. **Reading a post needs no share of the key; proposing one does.** A post is the one statement here meant for people who were not in the room, so a member holding no share reads the section like anybody else and simply gets no "Add post". Both gates are `canEditNostrProfile`, which is `canSign` read once for what is now four entry points. **And the caveat that bites hardest here: nothing has reached a relay.** `FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible to its members and to nobody else -- for a profile that is an inconvenience, for a post it is most of the point. The relay lists directly above the section say where these should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms rather than the neutral ones the other two readers use. `TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a `RitualNotice` like the other two -- nobody said it, the group signed it. The line names the post rather than quoting it: the post itself is on the group's screen where it can be read whole, and a quote would be the same words twice with the second copy unable to say who agreed to them. 10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape `FrostSigningManager.advance` runs, and three more in the section layout test -- ordering on screen, the empty state, and a member with no share reading posts they cannot add to. 1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`, 13 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@334e6dd1cb
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…