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"
+ }
+}