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 )