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
This commit is contained in:
Kgothatso Ngako
2026-09-10 11:46:06 +02:00
parent 590230f215
commit e74592a48e
15 changed files with 1311 additions and 15 deletions

View File

@@ -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()
}
}
}

View File

@@ -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<GroupPost> = 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) {

View File

@@ -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<GroupSignedEvent>,
chatRoomId: String,
): List<GroupPost> =
signedEvents
.mapNotNull { of(it, chatRoomId) }
.filter { it.content.isNotBlank() }
.sortedWith(compareByDescending<GroupPost> { 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> =
TextNoteEvent.build(note = note.trim())
}
}

View File

@@ -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<GroupRelayList>
/**
* 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<GroupPost>
/**
* 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> =
GroupRelayList.allAmong(emptyList(), chatRoomId)
override suspend fun groupPosts(chatRoomId: String): List<GroupPost> = emptyList()
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun refuseSubgroup(

View File

@@ -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 = {}
)
}
}
}

View File

@@ -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.
*

View File

@@ -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<AddGroupPostRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddGroupPostRoute>()
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<AddGroupPostRoute> {
inclusive = true
}
}
}
)
}
composable<FrostSigningRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FrostSigningRoute>()

View File

@@ -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()

View File

@@ -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<Boolean> = 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
)
}
}
}
}

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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<GroupRelayList> = 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<GroupPost> = emptyList(),
/**
* Whether this device could sign a profile for the group.
*