diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 87c489ee..45d110c6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -215,6 +215,17 @@ Send message Share profile Shared key + Signing key + The key this room signs as, and the ceremony it came from. + What the group signed + This room signs as + Derivation path + Key ceremony + Agreed on + The signed event + Copy the signed event + Copied the signed event + This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature. Sign Sign in Sign in is not yet available while Mantra is in alpha testing. 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 a2c8112c..a367f6ba 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 @@ -2,7 +2,10 @@ package press.mantra.compose.database.repository import press.mantra.compose.database.GENESIS_AT import press.mantra.compose.database.MantraDatabase +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.managers.ChronicleManager +import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload @@ -61,6 +64,20 @@ class DatabaseChatRepository( userPublicKey = userPublicKey, ) + override suspend fun groupKeyState(chatRoomId: String): GroupKeyState? = + GroupKeyStateManager.keyStateFor(database, chatRoomId) + + override suspend fun signedGroupKeyStateEvent(chatRoomId: String): GroupSignedEvent? = try { + GroupKeyStateManager.signedEventFor(database, chatRoomId) + } catch (e: Throwable) { + // Reading it walks every key state this device holds through the same + // checks that let one be believed in the first place, and one that will + // not derive throws rather than answering. The screen wants to draw + // either way -- what it loses is the copyable event, not the room. + logger.e("Error reading the signed key state event for $chatRoomId", e) + null + } + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { return database.participantDao().findParticipantByPublicKey(publicKey) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 2f47e13b..7cb93895 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -234,6 +234,24 @@ object GroupKeyStateManager { suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId) + /** + * The event the group signed to say what [chatRoomId] signs with, as the + * group signed it. + * + * The statement itself rather than the reading of it in [signedStateFor], + * for a screen that shows a member what their group actually put a signature + * to -- and lets them copy it somewhere it can be checked. Found wherever it + * is filed, which for the room it is about is *not* that room: the group + * agrees this before the room exists, so the event lives under the NIP-17 + * room its ceremony ran in. + */ + suspend fun signedEventFor( + database: MantraDatabase, + chatRoomId: String + ): GroupSignedEvent? = + signedAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId) + ?.first + /** * The newest state for [chatRoomId] among some signed events, or null if * none of them is one. @@ -243,6 +261,21 @@ object GroupKeyStateManager { * ceremony screen knows the group has finished agreeing. */ fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? = + signedAmong(signedEvents, chatRoomId)?.second + + /** + * The newest signed event for [chatRoomId] and the state it amounts to. + * + * The two travel together because neither is worth having without the other + * here: the state is what the app acts on, and the event is the group's own + * statement of it -- and an event that produces no state is one this device + * would not have believed, so it must not be shown as though the group had + * settled anything. + */ + private fun signedAmong( + signedEvents: List, + chatRoomId: String + ): Pair? = signedEvents .asSequence() .filter { it.kind == GroupKeyStateEvent.KIND } @@ -250,8 +283,8 @@ object GroupKeyStateManager { // the room an event arrived in; this one is walking events from // every room at once, so a state that names no room names nothing. .filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId } - .mapNotNull { stateFrom(chatRoomId, it.toEvent()) } - .maxByOrNull { it.announcedAt } + .mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } } + .maxByOrNull { (_, state) -> state.announcedAt } /** * The state an event amounts to, or null if it amounts to none. 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 dcec7bec..4be1d1e5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -2,6 +2,8 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.GiftWrapPayload +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MarmotKeyPackage import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.intermdiate.LocalChatMessage @@ -32,6 +34,27 @@ interface ChatRepository { */ suspend fun requestGroupHistoryIfMissing(chatRoomId: String, userPublicKey: HexKey): Boolean + /** + * What the group has said this room signs with, or null while it has said + * nothing. + * + * The row the app resolves a signing request against -- see + * `FrostSigningManager.completedKey` -- and the thing the group detail screen + * puts at the top, because which key a room signs as is the one fact about a + * room that decides whether anything it signs will be believed. + */ + suspend fun groupKeyState(chatRoomId: String): GroupKeyState? + + /** + * The event the group signed to say it, as the group signed it. + * + * Kept apart from [groupKeyState] because they can be missing separately and + * mean different things when they are. The state is this device's reading; + * this is the statement, with the signature on it, for a member who wants to + * see or check what the group actually put its key to. + */ + suspend fun signedGroupKeyStateEvent(chatRoomId: String): GroupSignedEvent? + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List @@ -154,6 +177,12 @@ interface ChatRepository { userPublicKey: HexKey ): Boolean = false + override suspend fun groupKeyState(chatRoomId: String): GroupKeyState? = null + + override suspend fun signedGroupKeyStateEvent( + chatRoomId: String + ): GroupSignedEvent? = null + override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return flow { } } 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 59436e3f..de4e9e98 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 @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.AccountTree import androidx.compose.material.icons.filled.Autorenew 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.Draw import androidx.compose.material.icons.filled.LibraryBooks @@ -27,6 +28,7 @@ import androidx.compose.material.icons.filled.WaterfallChart import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider @@ -40,12 +42,23 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.Participant import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -58,7 +71,11 @@ 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.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ChatRoomDetailViewModel @@ -88,6 +105,17 @@ import mantra.composeapp.generated.resources.projects import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.reindex_events import mantra.composeapp.generated.resources.shared_key +import mantra.composeapp.generated.resources.signing_key +import mantra.composeapp.generated.resources.the_key_this_room_signs_as +import mantra.composeapp.generated.resources.what_the_group_signed +import mantra.composeapp.generated.resources.this_room_signs_as +import mantra.composeapp.generated.resources.derivation_path +import mantra.composeapp.generated.resources.key_ceremony +import mantra.composeapp.generated.resources.agreed_on +import mantra.composeapp.generated.resources.the_signed_event +import mantra.composeapp.generated.resources.copy_the_signed_event +import mantra.composeapp.generated.resources.copied_the_signed_event +import mantra.composeapp.generated.resources.this_device_does_not_hold_the_event import mantra.composeapp.generated.resources.event_s_still_unreadable import mantra.composeapp.generated.resources.recovered_of_event_s import mantra.composeapp.generated.resources.recovered_of_still_unreadable @@ -96,6 +124,7 @@ import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.theme.ConformancePreviews +import kotlin.time.Instant @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -151,11 +180,63 @@ fun ChatRoomDetailScreen( Column( modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { + var isShowingGroupKeyState by rememberSaveable { mutableStateOf(false) } + LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { + // First on the screen, because it is the fact the rest + // of the room's signed work stands on: which key the + // room signs as decides whether anything it has signed + // will be believed anywhere. + // + // Absent rather than empty on a room the group has said + // nothing about. There is no half state to report -- a + // room either has one a quorum signed or has none -- and + // the shared key entry further down is where somebody + // goes to make one. + chatRoomDetailUIState.groupKeyState?.let { groupKeyState -> + item { + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { isShowingGroupKeyState = true } + ) { + ListItem( + leadingContent = { + Icon(Icons.Default.Key, contentDescription = Decorative) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text(text = stringResource(Res.string.signing_key)) + }, + supportingContent = { + // The identity, not the group's root + // key: this is the pubkey a reader + // sees on everything the room signs, + // and the room's own id. Truncated + // here and whole in the sheet, where + // it can be compared. + Text( + text = groupKeyState.announcedBy + .take(16) + .inComparableGroups(), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( @@ -609,6 +690,16 @@ fun ChatRoomDetailScreen( } } } + + if (isShowingGroupKeyState) { + chatRoomDetailUIState.groupKeyState?.let { groupKeyState -> + GroupKeyStateSheet( + groupKeyState = groupKeyState, + signedEvent = chatRoomDetailUIState.signedGroupKeyStateEvent, + onDismiss = { isShowingGroupKeyState = false } + ) + } + } } } } @@ -655,6 +746,199 @@ fun ChatRoomDetailScreen( } } +/** + * What the group signed to say what this room signs with, and a way to take it + * somewhere else. + * + * The fields first, because they are what a member came to read: the identity + * the room signs as, the key behind it, the path between them, and which + * ceremony made it. Then the event itself, which is the only part that can be + * *checked* -- a signature by that same identity over those same fields -- and + * so the only part worth copying. + * + * Whole values throughout, monospaced and wrapping. These exist to be compared + * character by character, against another member's screen or against what a + * relay reports, and a truncated key cannot be compared. + * + * [signedEvent] can be missing while [groupKeyState] is not -- a device that + * kept the reading and lost the statement -- and the sheet says so rather than + * showing the fields as though they were signed. It never happens the other way + * round: a state is only ever written from an event that passed both checks. + */ +@Composable +private fun GroupKeyStateSheet( + groupKeyState: GroupKeyState, + signedEvent: GroupSignedEvent?, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismiss = onDismiss) { + GroupKeyStateSheetContent(groupKeyState = groupKeyState, signedEvent = signedEvent) + } +} + +/** + * The sheet's body, apart from the sheet. + * + * Split out so it can be rendered and measured on its own -- a bottom sheet is a + * popup with its own window, which a layout test cannot reach into. See + * `GroupKeyStateSheetLayoutJvmTest`, which is checking the one thing that is not + * obvious from reading this: that a 500-character line of JSON with almost no + * spaces in it wraps inside the sheet rather than running off the side of it. + */ +@Composable +internal fun GroupKeyStateSheetContent( + groupKeyState: GroupKeyState, + signedEvent: GroupSignedEvent? +) { + val clipboardManager = LocalClipboardManager.current + + // Read out here rather than in the click handler: both of these are + // composable, and an onClick lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val copied = stringResource(Res.string.copied_the_signed_event) + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(Res.string.what_the_group_signed), + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.the_key_this_room_signs_as), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + // The identity every reader will see on this room's signed events, which + // is also the room's own id -- see SharedKeyDerivation.marmotGroupId, + // where those are one value. + GroupKeyStateField( + label = stringResource(Res.string.this_room_signs_as), + value = groupKeyState.announcedBy.inComparableGroups() + ) + + GroupKeyStateField( + label = stringResource(Res.string.shared_key), + value = groupKeyState.thresholdPublicKey.inComparableGroups() + ) + + GroupKeyStateField( + label = stringResource(Res.string.derivation_path), + value = groupKeyState.derivationPath + ) + + GroupKeyStateField( + label = stringResource(Res.string.key_ceremony), + value = groupKeyState.dkgSessionId.inComparableGroups() + ) + + GroupKeyStateField( + label = stringResource(Res.string.agreed_on), + value = groupKeyState.announcedAt.toFormattedTimeAndDateString(), + isMonospaced = false + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + Text( + text = stringResource(Res.string.the_signed_event), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + if (signedEvent == null) { + Text( + text = stringResource(Res.string.this_device_does_not_hold_the_event), + style = MaterialTheme.typography.bodySmall + ) + return@Column + } + + // As the group signed it, byte for byte. What is copied is exactly what + // is shown, because the point of copying it is to hand somebody + // something they can verify -- and a prettier rendering would be a + // different string to the one whose id was hashed. + val json = signedEvent.toEvent().toJson() + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) + ) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(json)) + notify(copied) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.copy_the_signed_event)) + } + } +} + +/** + * A long hex value in groups of eight, which is what makes it readable and what + * lets it wrap at all. + * + * Both halves matter. A 64-character key is compared against another member's + * screen a character at a time, and the eye loses its place in an undivided run + * of hex -- the same reason a fingerprint or an account number is grouped. + * + * And a run of hex has no space in it, so Compose has nowhere to break the line: + * it lays the whole thing out on one and lets it run off the side of the sheet, + * where the end of the key cannot be read at all. The spaces are the break + * opportunities. Nothing is copied from these -- the copy button takes the + * signed event, which is not grouped -- so the display can be shaped for reading + * without a paste losing the value. + */ +private fun String.inComparableGroups(): String = chunked(8).joinToString(" ") + +/** One labelled value of a key state, whole and comparable. */ +@Composable +private fun GroupKeyStateField( + label: String, + value: String, + isMonospaced: Boolean = true +) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + fontFamily = if (isMonospaced) FontFamily.Monospace else null, + // Stated rather than inherited: the label above is deliberately quieter, + // and the value is the thing that was come for. + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) +} + /** * Reads the room's stored marmot group events again. * @@ -776,6 +1060,17 @@ private fun ChatRoomMessagingScreenPreview() { ) ) ) + ), + // A room that has one, so the signing key row renders under + // all five conditions rather than only in a group that has + // held a ceremony. Not a real key: nothing here derives. + groupKeyState = GroupKeyState( + chatRoomId = "publicKey", + dkgSessionId = "c".repeat(64), + thresholdPublicKey = "02".padEnd(66, 'a'), + derivationPath = "m/9420/0/0", + announcedBy = "d".repeat(64), + announcedAt = Instant.fromEpochSeconds(1_700_000_000) ) ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, 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 b2cff93a..f1d0faad 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 @@ -78,6 +78,8 @@ class ChatRoomDetailViewModel( localChatRoom = localChatRoom, artifacts = mantraRepository.getArtifacts(chatRoomId), dialects = mantraRepository.getDialects(chatRoomId), + groupKeyState = chatRepository.groupKeyState(chatRoomId), + signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), ) } } 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 7d710357..881da4b1 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 @@ -1,5 +1,7 @@ package press.mantra.compose.ui.view.state +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.MantraArtifact import press.mantra.compose.database.model.MantraDialect import press.mantra.compose.database.model.intermdiate.LocalChatRoom @@ -9,6 +11,24 @@ sealed interface ChatRoomDetailUIState { val localChatRoom: LocalChatRoom, val artifacts: List = emptyList(), val dialects: List = emptyList(), + /** + * What the group has said this room signs with, or null while it has + * said nothing. + * + * Sits above everything else on the screen because it is the fact the + * rest of the room's signed work stands on: which key the room signs as + * decides whether anything it has signed will be believed. + */ + val groupKeyState: GroupKeyState? = null, + /** + * The event the group signed to say it, as the group signed it. + * + * Held beside [groupKeyState] rather than derived from it, because a + * state is this device's reading and this is the statement -- with the + * signature on it, which is the part worth copying somewhere it can be + * checked. Null on a device that holds the reading and not the event. + */ + val signedGroupKeyStateEvent: GroupSignedEvent? = null, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt new file mode 100644 index 00000000..3f856919 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupKeyStateSheetLayoutJvmTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.time.Instant +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.TorchTheme + +/** + * The one thing about this sheet that reading it does not settle. + * + * Everything in it is a `Text` in a `Column`, which needs no test -- except that + * what those texts hold is hex and JSON: a 64-character key, a 128-character + * signature, and an event serialised as one line with almost no spaces in it. + * Compose wraps at word boundaries, and a "word" here is longer than any phone + * is wide. If it does not break inside a token, the sheet renders a line running + * off the side of the screen with the signature unreadable and the copy button + * the only way to get at it. + * + * So this measures a real composition at phone width and asserts the content + * stayed inside it. It is also why the sheet's body is a composable of its own: + * a bottom sheet is a popup in its own window, which a layout test cannot reach. + */ +@OptIn(ExperimentalTestApi::class) +class GroupKeyStateSheetLayoutJvmTest { + + private val thresholdPublicKey = "02" + "a1b2c3d4".repeat(8) + + private val adminRoomId = "d4c3b2a1".repeat(8) + + private val keyState = GroupKeyState( + chatRoomId = adminRoomId, + dkgSessionId = "c".repeat(64), + thresholdPublicKey = thresholdPublicKey, + derivationPath = "m/9420/0/0", + announcedBy = adminRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000) + ) + + private val signedEvent = GroupSignedEvent.fromEvent( + event = Event( + id = "e".repeat(64), + pubKey = adminRoomId, + createdAt = 1_700_000_000, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = adminRoomId, + dkgSessionId = "c".repeat(64) + ), + content = thresholdPublicKey, + sig = "f".repeat(128) + ), + chatRoomId = adminRoomId, + derivationPath = "m/9420/0/0", + ) + + /** What the sheet is asked to fit into, and what it has to wrap inside. */ + private val phoneWidth = 360.dp + + /** Exactly what the sheet shows and the copy button copies. */ + private val json = signedEvent.toEvent().toJson() + + @Test + fun `the signed event wraps inside the sheet rather than running off it`() = + runDesktopComposeUiTest(width = 800, height = 2400) { + setContent { + TorchTheme { + // The sheet reports a copy through the snackbar host, so it + // needs one in scope the way every screen does. + ProvideSnackbarHost { + Box(modifier = Modifier.width(phoneWidth).testTag(SHEET)) { + GroupKeyStateSheetContent( + groupKeyState = keyState, + signedEvent = signedEvent + ) + } + } + } + } + + // The measurement that means something. A parent this narrow caps + // the text's *layout* width whether it wraps or not, so width says + // nothing -- height is what separates eight wrapped lines from one + // clipped one. The JSON is over 400 characters at roughly 50 to a + // line, so anything under four lines means it did not break. + onNodeWithText(json).assertHeightIsAtLeast(60.dp) + onNodeWithTag(SHEET).assertHeightIsAtLeast(200.dp) + } + + @Test + fun `a device holding no signed event still renders the state`() = + runDesktopComposeUiTest(width = 800, height = 2400) { + setContent { + TorchTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.width(phoneWidth).testTag(SHEET)) { + GroupKeyStateSheetContent( + groupKeyState = keyState, + signedEvent = null + ) + } + } + } + } + + onNodeWithTag(SHEET).assertHeightIsAtLeast(200.dp) + } + + private companion object { + const val SHEET = "group-key-state-sheet" + } +}