From e74592a48ea7bfe1d21d0b9f5905fbc025119d88 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Thu, 10 Sep 2026 11:46:06 +0200 Subject: [PATCH] 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 Pulled-From: curated/curated@334e6dd1cbad580c9d3753c0427388e557040fa5 --- .../composeResources/values/strings.xml | 11 + .../compose/database/model/ChatMessage.kt | 78 +++- .../repository/DatabaseChatRepository.kt | 13 + .../press/mantra/compose/nostr/GroupPost.kt | 121 +++++++ .../compose/repository/ChatRepository.kt | 13 + .../ui/composable/AddGroupPostScreen.kt | 333 ++++++++++++++++++ .../ui/composable/ChatRoomDetailScreen.kt | 145 ++++++++ .../ui/composable/navigation/MantraNavHost.kt | 22 ++ .../navigation/routes/AddGroupPostRoute.kt | 10 + .../ui/view/model/AddGroupPostViewModel.kt | 154 ++++++++ .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/state/AddGroupPostUIState.kt | 33 ++ .../ui/view/state/ChatRoomDetailUIState.kt | 10 + .../mantra/compose/nostr/GroupPostTest.kt | 307 ++++++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 75 +++- 15 files changed, 1311 insertions(+), 15 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 087a4478..e415143a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -446,4 +446,15 @@ Relays the group will not talk to. A group signs and cannot decrypt, so every list here is public — including the blocked one, which most clients keep private. Signed %1$s + Posts + Add post + Propose post + This group hasn't posted anything yet. + What should the group say? + The group signs a post, so it takes a quorum. It goes out in the group's name, not yours. + This group has no shared key, so it cannot sign a post. Run a shared key ceremony first. + A post needs something to say. + Couldn't ask the group to sign this post. + Posted %1$s + A reply diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 4de3f97f..020b3c39 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -21,6 +21,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupRelaySet import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -422,9 +423,21 @@ data class ChatMessage( * renders as a chat bubble, silently, looking exactly like somebody having * said "The group is now called Translation collective". */ + /** + * The group signed something to say publicly, in its own name. + * + * A system line rather than a bubble, like the other two -- nobody said it, + * the group signed it -- and the line names the post rather than repeating + * it, because the post itself is on the group's screen where it can be read + * whole. A quote here would be the same words twice with the second copy + * unable to say who agreed to them. + */ + const val TYPE_GROUP_POST_SIGNED = "groupPostSigned" + val GROUP_IDENTITY_TYPES = setOf( TYPE_GROUP_PROFILE_SIGNED, TYPE_GROUP_RELAYS_SIGNED, + TYPE_GROUP_POST_SIGNED, ) /** @@ -977,6 +990,22 @@ data class ChatMessage( createdAt: Instant, groupSignedEventId: HexKey? = null, ): ChatMessage? { + // The last resort, named because one arm reaches it deliberately rather + // than by falling off the end: a kind:1 the room did not sign is a + // member's own note travelling through the room, and it has always + // rendered as this. See the `TextNoteEvent` arm. + fun unsupported() = ChatMessage( + giftWrapPayloadId = null, + messageType = "unsupported", + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = event.toJson(), + ) + return when (event.kind) { ChatEvent.KIND -> { ChatMessage( @@ -1429,6 +1458,41 @@ data class ChatMessage( ) } + // The group saying something publicly, in its own name -- which + // is a different thing from a member saying something in the room. + // A room's messages are `ChatEvent`s; this is a kind:1 authored by + // the room's own key, so any reader can check the group said it and + // that no single member could have. + // + // Verified like the arms below and for the same reason: a rumor + // carries an empty signature and any pubkey its sender likes, so an + // author check alone would let one member put words in the group's + // mouth in its own transcript. + // + // **Unlike those arms, a kind:1 that fails falls through to + // `unsupported` rather than vanishing.** A member's kind:0 or relay + // list passing through a room is identity plumbing nobody sent on + // purpose and silence is the right treatment; a kind:1 is content, + // somebody meant it, and it has rendered as an unsupported event + // since before any of this existed. Dropping it here would be this + // change quietly deleting messages it has no business touching. + TextNoteEvent.KIND -> { + val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId) + if (!signed.verifies()) return unsupported() + + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_GROUP_POST_SIGNED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group posted in its own name" + ) + } + // The group saying where it lives on nostr: which relays carry // its work, take its messages, answer a search of it, and which it // will not talk to. @@ -1471,19 +1535,7 @@ data class ChatMessage( ) } - else -> { - ChatMessage( - giftWrapPayloadId = null, - messageType = "unsupported", - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = event.toJson(), - ) - } + else -> unsupported() } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 25a629df..277f3755 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import press.mantra.compose.database.model.ChatMessage @@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -157,6 +159,17 @@ class DatabaseChatRepository( GroupRelayList.allAmong(emptyList(), chatRoomId) } + override suspend fun groupPosts(chatRoomId: String): List = try { + GroupPost.newestFirstAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, TextNoteEvent.KIND), + chatRoomId = chatRoomId, + ) + } catch (e: Throwable) { + logger.e("Error reading the posts of $chatRoomId", e) + emptyList() + } + override suspend fun canSign(chatRoomId: String): Boolean = try { FrostSigningManager.canSign(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt new file mode 100644 index 00000000..adbe327b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupPost.kt @@ -0,0 +1,121 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.time.Instant + +/** + * A kind:1 the group signed: something it said publicly, as itself. + * + * The room's id is the pubkey it signs as, so a note authored by that key is the + * *group* posting rather than a member posting in the group. Those are different + * things and the room already has the second one -- the transcript. This is the + * first: a statement the group put a quorum's signature to, which any reader can + * check came from the group and not from whoever typed it. + * + * ### Why this is not a `Post` row + * + * `Post` hangs off both `NostrEvent` and `Profile` by foreign key, and a group has + * neither: a group-signed event is not a `NostrEvent` -- see `GroupNostrProfile`, + * which gives the reasoning at length -- and the group has no `Profile` row for the + * same reason. So a group's posts live where the group's other signed statements + * live, and this is the reading of them. + * + * ### Nothing here has reached a relay + * + * The same caveat `GroupNostrProfile` and `GroupRelayList` carry, and it bites + * hardest here: a post is the one kind of statement whose entire purpose is to be + * read by people outside the group. `FrostSigningManager.complete` applies signed + * events locally and puts nothing on the wire, so for now a group's posts are + * visible to its members and to nobody else. The relay lists beside them say where + * they should go; sending them is a job that does not exist yet. + */ +data class GroupPost( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is the order posts are read in. */ + val postedAt: Instant get() = signedEvent.createdAt + + /** What the group said. */ + val content: String get() = signedEvent.content + + /** + * Whether this is the group speaking rather than answering. + * + * A kind:1 with no `e` tag starts a thread; one with them is a reply. Nothing + * here writes a reply yet -- the composer builds a bare note -- so this exists + * to keep an imported or chronicled reply from being drawn as an announcement. + */ + fun isNewThread(): Boolean = asTextNoteEvent().isNewThread() + + /** The event as the group signed it. */ + fun asTextNoteEvent(): TextNoteEvent = signedEvent.toEvent().let { event -> + TextNoteEvent( + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + tags = event.tags, + content = event.content, + sig = event.sig, + ) + } + + companion object { + /** + * The reading of one signed event, or null when it is not one of these. + * + * The same gate the profile and the relay lists use: the wrong kind, or a + * signature that does not check out as [chatRoomId]'s, and a caller gets + * nothing. A kind:1 a member sent into the room is that member's note, not + * the group's, and fails on the author. + */ + fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupPost? { + if (signedEvent.kind != TextNoteEvent.KIND) return null + if (!signedEvent.verifies()) return null + + return GroupPost(signedEvent) + } + + /** + * Every post [chatRoomId] signed among [signedEvents], newest first. + * + * Newest first because that is how a feed is read, and the id breaks a tie + * so two devices holding the same posts show them in the same order. Unlike + * a profile or a relay list these accumulate rather than replace: kind:1 is + * not replaceable, so a second post stands beside the first. + * + * Blank posts are dropped. A signed note with nothing in it renders as an + * empty card that cannot be told from a broken one, and the composer will + * not produce one -- so anything that reaches here empty came from + * somewhere this app does not control. + */ + fun newestFirstAmong( + signedEvents: List, + chatRoomId: String, + ): List = + signedEvents + .mapNotNull { of(it, chatRoomId) } + .filter { it.content.isNotBlank() } + .sortedWith(compareByDescending { it.postedAt }.thenByDescending { + it.signedEvent.id + }) + + /** + * The event to ask the group to sign for a post saying [note]. + * + * A bare kind:1: no reply tags, no mentions, no subject. What the group is + * doing here is speaking, and every tag `TextNoteEvent.build` can add + * describes a relationship to something else -- a thread to reply into, a + * pubkey to notify -- which a group's first way of posting does not have + * and should not guess at. + */ + fun template(note: String): EventTemplate = + TextNoteEvent.build(note = note.trim()) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 71fd8fea..9b44ee7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage @@ -102,6 +103,16 @@ interface ChatRepository { */ suspend fun groupRelayLists(chatRoomId: String): List + /** + * The kind:1s this group signed, newest first. + * + * The group speaking as itself, which is a different thing from a member + * speaking in the room -- the transcript is the second. Unlike the profile and + * the relay lists these accumulate rather than replace, so this is a list and + * an empty one means the group has said nothing publicly. + */ + suspend fun groupPosts(chatRoomId: String): List + /** * Whether this device holds a share of the group's key, and so could take * part in signing for it. @@ -288,6 +299,8 @@ interface ChatRepository { override suspend fun groupRelayLists(chatRoomId: String): List = GroupRelayList.allAmong(emptyList(), chatRoomId) + override suspend fun groupPosts(chatRoomId: String): List = emptyList() + override suspend fun canSign(chatRoomId: String): Boolean = false override suspend fun refuseSubgroup( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt new file mode 100644 index 00000000..ac0bdc77 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddGroupPostScreen.kt @@ -0,0 +1,333 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.AddGroupPostViewModel +import press.mantra.compose.ui.view.state.AddGroupPostUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_post_needs_something_to_say +import mantra.composeapp.generated.resources.add_post +import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_post +import mantra.composeapp.generated.resources.propose_post +import mantra.composeapp.generated.resources.the_group_signs_a_post_so_it_takes_a_quorum +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_post +import mantra.composeapp.generated.resources.what_should_the_group_say +import org.jetbrains.compose.resources.stringResource + +/** + * The draft of something the group would say, in its own name. + * + * Not a message in the room: this is signed by the group's key and read by anybody, + * which is why the byline over the draft is the group's rather than the writer's. + * Whoever types it is proposing words for the group to put its signature to, and + * the words become a post -- attributed to the group and to nobody in particular -- + * only once a quorum has signed. + * + * The byline is the whole of the design here. Every other composer in this app puts + * the author's name over the draft because the author is the person typing; getting + * that wrong on this screen would have a member writing what looks like their own + * note and finding the group had said it. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AddGroupPostScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, + onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, +) { + val addGroupPostViewModel: AddGroupPostViewModel = viewModel( + factory = AddGroupPostViewModel.factory( + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddGroupPostUIState = initialAddGroupPostUIState, + activeUserPublicKey = activeUserPublicKey, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + // Read out here rather than in the click handler: both are composable and an + // onClick lambda is not. The scope is the caller's so a message survives this + // screen being replaced by the signing session. + val notify = rememberNotifier(rememberCoroutineScope()) + val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_post) + val nothingToSay = stringResource(Res.string.a_post_needs_something_to_say) + + ScreenStateTransition(addGroupPostViewModel.addGroupPostUIState) { uiState -> + when (val addGroupPostUIState = uiState) { + is AddGroupPostUIState.Error -> { + // Nothing to retry: the room came from a navigation argument, and + // reading it again with the same one fails the same way. + ErrorState(message = addGroupPostUIState.message, onRetry = null) + } + + is AddGroupPostUIState.Loaded -> { + val noteFieldState = rememberTextFieldState() + val canSign = addGroupPostUIState.canSign + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen is a text field the size of the screen, + // and without it the software keyboard covers what is being typed. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(text = stringResource(Res.string.add_post)) } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canSign) return@ExtendedFloatingActionButton + + addGroupPostViewModel.proposePost( + localChatRoom = addGroupPostUIState.localChatRoom, + noteField = noteFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back + // to the group. Nothing has been + // posted yet -- the post appears + // when a quorum signs -- so landing + // on the list it is not in would + // read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + // Both stay on the draft. One is an + // empty note and one is a failure to + // propose, and neither is a reason to + // throw away what was written. + onEmpty = { notify(nothingToSay) }, + onFailure = { notify(couldNotPropose) } + ) + } + ) { + Icon(Icons.Default.Draw, contentDescription = "Propose post") + Text(stringResource(Res.string.propose_post)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Whose name this goes out in, said before a word is typed. + // It is the group's, and it is the one thing about this + // screen a member could reasonably get wrong. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileAvatar( + size = MaterialTheme.spacing.space500, + publicKey = chatRoomId, + picture = addGroupPostUIState.groupNostrProfile?.picture(), + name = addGroupPostUIState.groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Text( + text = addGroupPostUIState.groupNostrProfile?.name() + ?: addGroupPostUIState.localChatRoom.chatRoom.subject + ?: chatRoomId.take(16), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.the_group_signs_a_post_so_it_takes_a_quorum + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (!canSign) { + Text( + text = stringResource( + Res.string.this_group_has_no_shared_key_to_sign_a_post + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f) + .background(BottomAppBarDefaults.containerColor), + state = noteFieldState, + // The draft grows into the screen rather than scrolling + // inside three lines: a post is the one thing here + // somebody writes at length, and a composer that hides + // what was written two sentences ago is a composer + // nobody proofreads in. + lineLimits = TextFieldLineLimits.MultiLine(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + placeholder = { + Text(stringResource(Res.string.what_should_the_group_say)) + } + ) + } + } + } + + AddGroupPostUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + Text( + text = stringResource(Res.string.add_post), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator(fillScreen = false) + } + } + } + } + + LaunchedEffect(true) { + if (initialAddGroupPostUIState == AddGroupPostUIState.Loading) { + addGroupPostViewModel.initiateAddGroupPost() + } + } +} + +@ConformancePreviews +@Composable +private fun AddGroupPostScreenPreview() { + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AddGroupPostScreen( + activeUserPublicKey = "", + chatRoomId = "publicKey", + relayHint = null, + initialAddGroupPostUIState = AddGroupPostUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "publicKey", + userPublicKey = "", + subject = "Translation room", + description = "A group translating hard books.", + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ), + ), + // A group that can sign, so the composer renders in the state a + // member actually meets it in rather than greyed out. + canSign = true + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + onNavigateToRouteAndPopUpInclusive = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 6dce2aef..96894ba6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Draw import androidx.compose.material.icons.filled.LibraryBooks @@ -71,6 +73,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -94,6 +97,11 @@ import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.nostr_profile import mantra.composeapp.generated.resources.relays +import mantra.composeapp.generated.resources.posts +import mantra.composeapp.generated.resources.add_post +import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.posted_on +import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet import mantra.composeapp.generated.resources.edit_relays import mantra.composeapp.generated.resources.not_set_yet import mantra.composeapp.generated.resources.no_relays_in_this_list @@ -117,6 +125,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet import mantra.composeapp.generated.resources.Res @@ -448,6 +457,70 @@ fun ChatRoomDetailScreen( } } + // Under the relay lists, because these are what the + // lists are *for*: the profile says who the group + // is, the relays say where to find it, and these are + // what a reader would find there. Still above the + // library, which is the group's work rather than its + // voice. + item { + Text( + text = stringResource(Res.string.posts), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.groupPosts.isEmpty()) { + item { + Text( + stringResource( + Res.string.this_group_has_not_posted_anything_yet + ) + ) + } + } else { + items( + items = chatRoomDetailUIState.groupPosts, + key = { post -> post.signedEvent.id } + ) { post -> + GroupPostCard( + post = post, + publicKey = chatRoomId, + groupNostrProfile = + chatRoomDetailUIState.groupNostrProfile + ) + } + } + + if (chatRoomDetailUIState.canEditNostrProfile) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + AddGroupPostRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Campaign, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.add_post)) + } + } + } + item { HorizontalDivider() } @@ -1324,6 +1397,78 @@ private fun GroupRelaySet.summaryLabel() = when (this) { GroupRelaySet.Blocked -> Res.string.relay_set_blocked } +/** + * One thing the group said, as the group signed it. + * + * The whole note rather than a preview. A post is short by construction -- kind:1 + * with no long-form arm behind it -- and truncating the one kind of content on this + * screen that exists to be *read* would mean opening something to see a paragraph. + * + * **The byline is the group's**, taken from its profile where it has one and from + * the room's own id where it has not. It is worth spelling out because a post is + * the one thing here a reader might otherwise attribute to whichever member + * proposed it -- and the point of signing it as the group is that they cannot. + * + * A reply is marked as one. Nothing in this app writes replies, so the mark is for + * an event that arrived some other way; drawing one as an announcement would put + * half a conversation on a group's page with no sign that it was half. + */ +@Composable +private fun GroupPostCard( + post: GroupPost, + publicKey: HexKey, + groupNostrProfile: GroupNostrProfile? +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ProfileAvatar( + size = MaterialTheme.spacing.space400, + publicKey = publicKey, + picture = groupNostrProfile?.picture(), + name = groupNostrProfile?.name() + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource( + Res.string.posted_on, + post.postedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!post.isNewThread()) { + Text( + text = stringResource(Res.string.a_reply), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = post.content, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 7273e49d..d3be59ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -41,6 +41,7 @@ import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen +import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen import press.mantra.compose.ui.composable.ImplementationPendingScreen @@ -125,6 +126,7 @@ import press.mantra.compose.ui.composable.AddArtifactScreen import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute @@ -1012,6 +1014,26 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddGroupPostScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace the composer so back returns to the group rather + // than to a draft whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt new file mode 100644 index 00000000..7400f78f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/AddGroupPostRoute.kt @@ -0,0 +1,10 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +@Serializable +data class AddGroupPostRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String? +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt new file mode 100644 index 00000000..a4fd0d0e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddGroupPostViewModel.kt @@ -0,0 +1,154 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.view.state.AddGroupPostUIState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch + +/** + * Asks the group to say something publicly, in its own name. + * + * The post is not written here and does not exist yet. What goes out is a proposal + * to sign one, and the post appears -- on every member's device at once, authored by + * the room's own key rather than by whoever typed it -- when enough members have + * signed. That author is the room's id: signing runs at the path the room was + * derived at, so a post says which group made it simply by being signed. + * + * That is the difference from a message in the room. A message is a member speaking + * where the group can hear; this is the group speaking where anyone can, and it + * takes a quorum to say. + */ +class AddGroupPostViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState, + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository, +): ViewModel() { + + var addGroupPostUIState: AddGroupPostUIState by mutableStateOf(initialAddGroupPostUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiateAddGroupPost() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + + addGroupPostUIState = if (localChatRoom == null) { + AddGroupPostUIState.Error("Couldn't find the chat room") + } else { + AddGroupPostUIState.Loaded( + localChatRoom = localChatRoom, + groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), + // A NIP-17 room has no key of its own to sign as, so it has no + // identity to post under -- the same condition the group detail + // screen gates the entry point on. + canSign = localChatRoom.chatRoom.mlsGroupState != null && + frostSigningRepository.canSign(chatRoomId), + ) + } + } + } + + /** + * Opens a session over one kind:1 authored by the group. + * + * Refuses a blank draft rather than proposing it: a quorum signing an empty note + * would put a post on the record that renders as nothing and cannot be told from + * a broken one -- and kind:1 is not replaceable, so there is no un-saying it. + * `GroupPost.newestFirstAmong` drops blanks on the way back out too, which is + * the second line of the same defence. + * + * The draft is not cleared on success. This screen is replaced by the signing + * session, and on the failure path the words are the thing worth keeping. + */ + fun proposePost( + localChatRoom: LocalChatRoom, + noteField: TextFieldState, + onSuccess: (sessionId: String) -> Unit, + onEmpty: () -> Unit, + onFailure: () -> Unit + ) { + // Guard against double submits. Posts accumulate rather than replace, so a + // second session over the same words is a second post on the record forever. + if (isActionPending.value) return + + val note = noteField.text.toString().trim() + if (note.isBlank()) { + onEmpty.invoke() + return + } + + isActionPending.value = true + + val template = GroupPost.template(note) + + viewModelScope.launch(Dispatchers.IO) { + val session = runCatching { + frostSigningRepository.proposeSigning( + localChatRoom = localChatRoom, + userPublicKey = activeUserPublicKey, + kind = template.kind, + tags = template.tags, + content = template.content, + ) + }.onFailure { error -> + logger.e("Failed to propose a post for $chatRoomId", error) + }.getOrNull() + + viewModelScope.launch(Dispatchers.Main) { + if (session != null) { + onSuccess.invoke(session.id) + } else { + onFailure.invoke() + } + } + + isActionPending.value = false + } + } + + companion object { + private const val TAG = "AddGroupPostViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading, + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + AddGroupPostViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + initialAddGroupPostUIState = initialAddGroupPostUIState, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 9a807bf7..144507b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -90,6 +90,7 @@ class ChatRoomDetailViewModel( parentChatRoomId = chatRepository.parentOf(chatRoomId), groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId), groupRelayLists = chatRepository.groupRelayLists(chatRoomId), + groupPosts = chatRepository.groupPosts(chatRoomId), canEditNostrProfile = canSign, canAddSubgroup = canSign, ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt new file mode 100644 index 00000000..4bcbffb3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddGroupPostUIState.kt @@ -0,0 +1,33 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupNostrProfile + +sealed interface AddGroupPostUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** + * Who the group says it is, for the byline over the draft. + * + * A post is signed by the group and read as the group, so the composer + * shows whose voice is being written in -- which is not the member typing. + * Null in a group that has not described itself, and the byline falls back + * to the room. + */ + val groupNostrProfile: GroupNostrProfile? = null, + /** + * Whether this device holds a share of the group's key. + * + * False leaves the draft writable and the button inert, for the same reason + * the other two editors do: reading and composing cost nothing, and only a + * share-holder can open the session that would sign it. + */ + val canSign: Boolean = false, + ): AddGroupPostUIState + + data class Error( + val message: String + ): AddGroupPostUIState + + data object Loading: AddGroupPostUIState +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 394336cd..e18535f4 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -8,6 +8,7 @@ import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList sealed interface ChatRoomDetailUIState { @@ -74,6 +75,15 @@ sealed interface ChatRoomDetailUIState { * four entries, some of them unsigned; see `ChatRepository.groupRelayLists`. */ val groupRelayLists: List = emptyList(), + /** + * What the group has said publicly, as itself, newest first. + * + * Under the relay lists because it is the thing they are *for*: the profile + * says who the group is, the relay lists say where to find it, and these + * are what a reader would find there. Empty in a group that has said + * nothing, which is every group until somebody proposes one. + */ + val groupPosts: List = emptyList(), /** * Whether this device could sign a profile for the group. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt new file mode 100644 index 00000000..53fc1c73 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupPostTest.kt @@ -0,0 +1,307 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What may be shown as something the group said. + * + * The reading gate is `GroupNostrProfileTest`'s -- the room has to be the author and + * the signature has to be the room's -- and it matters more here than anywhere else + * in this feature. A profile read wrong shows the wrong name; a post read wrong puts + * words in a group's mouth under a byline saying a quorum agreed to them, on a page + * whose whole purpose is to be read by people who were not there. + * + * The ordering half is the other thing worth pinning. Posts are the one statement + * here that **accumulates**: kind:1 is not replaceable, so a second post stands + * beside the first rather than replacing it, and everything the profile and the + * relay lists do with "newest wins" is wrong for these. + */ +class GroupPostTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the posts signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + @Test + fun `a kind one the room signed is a post by the group`() { + val post = GroupPost.of( + signedEvent = signed("We have finished the first chapter."), + chatRoomId = chatRoomId + ) + + assertEquals("We have finished the first chapter.", post?.content) + assertEquals(chatRoomId, post?.publicKey) + assertTrue(post?.isNewThread() == true) + } + + @Test + fun `a kind one another group signed is not this group's post`() { + // A real quorum and a real signature by a group with no standing to speak + // for this one. The row names this room because that is where it was filed; + // the author is what decides whose words they are. + val stranger = GroupSignedEvent.fromEvent( + event = noteEvent("Said by somebody else entirely.", material = strangerMaterial), + chatRoomId = chatRoomId + ) + + assertNull(GroupPost.of(signedEvent = stranger, chatRoomId = chatRoomId)) + } + + @Test + fun `a kind one the room did not sign is not a post`() { + // Authored by the room and signed by somebody else: the shape a member + // putting words in the group's mouth would produce, and the reason the + // reading verifies rather than trusting the author field. + assertNull( + GroupPost.of( + signedEvent = signed("The group has decided to disband.", signer = strangerMaterial), + chatRoomId = chatRoomId + ) + ) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupPost.of( + signedEvent = signed("Anything at all", signature = rubbish), + chatRoomId = chatRoomId + ), + "a post signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not a post`() { + assertNull( + GroupPost.of( + signedEvent = signed("Hello").copy(kind = 30023), + chatRoomId = chatRoomId + ) + ) + } + + @Test + fun `posts accumulate rather than replace, newest first`() { + // The property that separates these from the profile and the relay lists. + // A reading that kept only the newest would silently delete a group's whole + // history the first time it posted twice. + val first = signed("First", createdAt = 1_700_000_000) + val second = signed("Second", createdAt = 1_700_000_900) + val third = signed("Third", createdAt = 1_700_001_800) + + listOf( + listOf(first, second, third), + listOf(third, second, first), + listOf(second, third, first), + ).forEach { events -> + assertEquals( + listOf("Third", "Second", "First"), + GroupPost.newestFirstAmong(events, chatRoomId).map { it.content }, + "posts came back in the wrong order, or one of them went missing" + ) + } + } + + @Test + fun `two posts signed in the same second resolve the same way on every device`() { + // Two devices reading the same posts in whatever order the query hands them + // over have to show the same feed. A tie on the timestamp breaks on the id, + // which both agree on because it is a hash of the event. + val one = signed("One", createdAt = 1_700_000_000) + val other = signed("Other", createdAt = 1_700_000_000) + + val expected = GroupPost.newestFirstAmong(listOf(one, other), chatRoomId).map { it.content } + + assertEquals( + expected, + GroupPost.newestFirstAmong(listOf(other, one), chatRoomId).map { it.content }, + "a tie on the timestamp was broken differently by the two orderings" + ) + assertEquals( + if (one.id > other.id) listOf("One", "Other") else listOf("Other", "One"), + expected, + "the tie should break on the id, which is the only thing both devices share" + ) + } + + @Test + fun `a blank post is dropped rather than drawn as an empty card`() { + // The composer will not produce one, so anything reaching here empty came + // from somewhere this app does not control -- and an empty signed card + // cannot be told from a broken one. + listOf("", " ", "\n\n").forEach { blank -> + assertEquals( + emptyList(), + GroupPost.newestFirstAmong(listOf(signed(blank)), chatRoomId), + "a post saying \"$blank\" was shown" + ) + } + } + + @Test + fun `a reply is marked as one`() { + // Nothing here writes replies, so this is about an event that arrived some + // other way. Drawing one as an announcement would put half a conversation on + // a group's page with no sign that it was half. + val reply = GroupPost.of( + signedEvent = signed( + "Yes, agreed.", + tags = arrayOf(arrayOf("e", "b".repeat(64), "", "reply")) + ), + chatRoomId = chatRoomId + ) + + assertFalse(reply?.isNewThread() == true) + } + + @Test + fun `the composer builds a bare note and trims it`() { + // No reply tags, no mentions, no subject: every tag `TextNoteEvent.build` + // can add describes a relationship to something else, which a group's first + // way of posting does not have and should not guess at. + val template = GroupPost.template(" Something the group would say. ") + + assertEquals(TextNoteEvent.KIND, template.kind) + assertEquals("Something the group would say.", template.content) + assertEquals(emptyList(), template.tags.toList()) + } + + @Test + fun `a post the composer built reads back as the group said it`() { + val template = GroupPost.template("We have finished the first chapter.") + val post = GroupPost.of( + signedEvent = signed(template.content, tags = template.tags), + chatRoomId = chatRoomId + ) + + assertEquals("We have finished the first chapter.", post?.content) + assertTrue(post?.isNewThread() == true) + } + + /** A post as `FrostSigningManager.complete` files one. */ + private fun signed( + note: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = noteEvent(note, tags, createdAt, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun noteEvent( + note: String, + tags: Array> = emptyArray(), + createdAt: Long = 1_700_000_000, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = tags, + content = note + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = createdAt, + kind = TextNoteEvent.KIND, + tags = tags, + content = note, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index 1d0d4eb4..fdf7711e 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -16,6 +16,7 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet @@ -27,6 +28,7 @@ import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.MantraTheme import press.mantra.compose.ui.view.state.ChatRoomDetailUIState import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import kotlin.test.Test import kotlin.test.assertTrue @@ -135,6 +137,58 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Edit relays").assertDoesNotExist() } + @Test + fun `a member holding no share of the key reads the posts and is offered no composer`() = + render( + state( + groupNostrProfile = profile, + posts = listOf(post("Something the group said", 1_700_000_000)), + canEditNostrProfile = false + ) + ) { + // A post is the one thing here that is meant to be read by people who + // were not in the room, so a member without a share reads it like + // anybody else and simply cannot propose the next one. + onNodeWithText("Something the group said").assertIsDisplayed() + onNodeWithText("Add post").assertDoesNotExist() + } + + @Test + fun `the posts sit under the relay lists and above the library`() = render( + state( + groupNostrProfile = profile, + relayLists = signedRelayLists(), + posts = listOf(post("The second thing", 1_700_000_900), post("The first thing", 1_700_000_000)) + ) + ) { + val relays = onNodeWithText("Relays").getBoundsInRoot().top + val posts = onNodeWithText("Posts").getBoundsInRoot().top + val library = onNodeWithText("Library").getBoundsInRoot().top + + assertTrue(relays < posts, "the posts climbed above the relay lists") + assertTrue(posts < library, "the posts fell below the library") + + // Newest first, and whole rather than previewed -- a post is the one thing + // on this screen that exists to be read. + val newest = onNodeWithText("The second thing").getBoundsInRoot().top + val oldest = onNodeWithText("The first thing").getBoundsInRoot().top + assertTrue(newest < oldest, "the posts were drawn oldest first") + + // Under the group's name, not the member's. The whole point of signing a + // post as the group is that no member can be blamed for it. + onAllNodesWithText("Translation collective").assertCountEquals(3) + onNodeWithText("Add post").assertIsDisplayed() + } + + @Test + fun `a group that has posted nothing says so, and offers to post`() = render( + state(groupNostrProfile = profile, relayLists = signedRelayLists()) + ) { + onNodeWithText("Posts").assertIsDisplayed() + onNodeWithText("This group hasn't posted anything yet.").assertIsDisplayed() + onNodeWithText("Add post").assertIsDisplayed() + } + @Test fun `a group that has said nothing says so, and offers to say something`() = render( state(groupNostrProfile = null) @@ -164,10 +218,12 @@ class GroupNostrProfileSectionJvmTest { .assertDoesNotExist() onNodeWithText("Edit Nostr profile").assertDoesNotExist() - // The relay lists go with it: they describe a nostr identity this room - // does not have. + // The relay lists and the posts go with it: all three describe a nostr + // identity this room does not have. onNodeWithText("Relays").assertDoesNotExist() onNodeWithText("Edit relays").assertDoesNotExist() + onNodeWithText("Posts").assertDoesNotExist() + onNodeWithText("Add post").assertDoesNotExist() // The rest of the screen is untouched, so the section's absence is an // absence rather than a screen that failed to draw. @@ -207,9 +263,23 @@ class GroupNostrProfileSectionJvmTest { createdAt = Instant.fromEpochSeconds(1_700_000_000) ) + private fun post(content: String, createdAt: Long) = GroupPost( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'b'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = TextNoteEvent.KIND, + tags = emptyArray(), + content = content, + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ) + ) + private fun state( groupNostrProfile: GroupNostrProfile?, relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), + posts: List = emptyList(), canEditNostrProfile: Boolean = true, mlsGroupState: String? = "state" ) = ChatRoomDetailUIState.Loaded( @@ -238,6 +308,7 @@ class GroupNostrProfileSectionJvmTest { ), groupNostrProfile = groupNostrProfile, groupRelayLists = relayLists, + groupPosts = posts, canEditNostrProfile = canEditNostrProfile )