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
)