Kgothatso Ngako e74592a48e feat(groups): what the group says out loud, and the one arm that must not swallow a message
`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
2026-09-13 11:58:00 +02:00
2026-09-09 17:04:09 +02:00
2026-03-24 04:47:19 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00

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…

Description
Mantra as a kotlin multiplatform project
https://mantra.press
Readme 13 MiB
Languages
Kotlin 100%