feat: put a room's signing key first on its detail screen, and the signed event behind it
A group's `GroupKeyState` had no surface anywhere in the app. It decides which share a member signs with and which identity a reader will see on everything the group signs, and the only way to learn either was to read the logs. The group detail screen now opens with it, and tapping it shows the event a quorum actually put its signature to, with a button to take that event somewhere it can be checked. **First on the screen, above the description.** Which key a room signs as is the fact the rest of the room's signed work stands on -- a dialect, an artifact and a chapter are all worth exactly what the identity behind them is worth -- so it goes before the library and the dialects rather than into the settings-ish tail of the screen with reindexing and leaving. It is absent rather than empty on a room the group has said nothing about: there is no half state to report, since a room either has one a quorum signed or has none, and the shared key entry further down is already where somebody goes to make one. **The row's subtitle is the identity, not the threshold key.** Those are different values -- the group's root ChillDKG key, and that key walked to the room's path -- and only the second one appears on anything. It is what a reader checks a signature against and it is the room's own id, so it is the value a member is most likely to want to compare against something. The whole of it, along with the root key it came from, is one tap away in the sheet. **The state and the event are read separately, and neither is derived from the other.** A `GroupSignedEvent` carries every field the `GroupKeyState` row does, so one read would have done -- but the two mean different things when they are missing. The row is this device's reading, which is what the app resolves a signing request against; the event is the group's statement, with the signature on it, which is the only part that can be checked. A device holding the reading and not the statement should not be shown fields as though they were signed, and the sheet says so instead. It never happens the other way round: a state is only ever written from an event that passed both checks. **`signedEventFor` looks wherever the event is filed, which is not this room.** Since the previous commit a group agrees its key state before the room exists, so the event lives under the NIP-17 room its ceremony ran in and is authored by the Marmot room it is about. Finding it by room would find nothing. It is found by its `d` tag instead, through the same `stateFrom` that lets one be believed at all, so nothing is shown that this device would not have acted on. `stateAmong` and the new `signedEventFor` are now one walk returning both halves, because an event that produces no state must not be shown as though the group had settled anything. **The sheet shows and copies the canonical compact event JSON.** Pretty-printing it would read better in the block and was rejected: the point of copying it is to hand somebody something they can verify, and the moment the display and the copy diverge the button stops being "copy what you are looking at". What is shown is `Event.toJson()`, byte for byte, which is what a nostr tool expects to be given. **The hex is grouped in eights, which the render caught and reading did not.** Captured off a real desktop composition at 360dp, the JSON wrapped fine -- it has quotes and commas to break on -- and every key ran off the side of the sheet with its last characters unreadable. A 64-character key has no space in it, so Compose lays the whole run on one line and lets it overflow. The spaces are the break opportunities. They are also how a value meant to be compared character by character against another member's screen should have been shown in the first place, for the same reason a fingerprint or an account number is grouped. Nothing is copied from those fields, so shaping them for reading costs a paste nothing. **The value colour is stated rather than inherited.** The labels are deliberately quieter at `onSurfaceVariant` and the values are what a member came for, so they name `onSurface` instead of taking whatever `LocalContentColor` happens to be. The JSON block sits on `surfaceVariant`/`onSurfaceVariant`, which `ColorSchemeContrastTest` already measures in all six schemes. **The sheet's body is a composable of its own, and that is what makes it testable.** A `ModalBottomSheet` is a popup in its own window, which a layout test cannot reach into, so `GroupKeyStateSheetContent` is separated from the sheet that contains it. `GroupKeyStateSheetLayoutJvmTest` then renders it at phone width and asserts the *height* of the JSON: a parent that narrow caps the text's layout width whether it wraps or clips, so width would pass either way. The threshold is calibrated against the real measurement rather than guessed -- it renders 224dp wrapped, against roughly 16dp for a single clipped line, so 60dp separates them with room to spare. A second case renders the sheet for a device holding no signed event, since that branch returns early and would otherwise never be laid out. The screen's `@ConformancePreviews` gains a key state, so the row renders under all five conditions rather than only in a group that has held a ceremony. 651 jvm tests and 373 common tests pass; `m3Audit` meets every budget, with the string count unchanged at 39 -- the eleven new pieces of UI text are in the catalogue in sentence case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,17 @@
|
||||
<string name="send_message">Send message</string>
|
||||
<string name="share_profile">Share profile</string>
|
||||
<string name="shared_key">Shared key</string>
|
||||
<string name="signing_key">Signing key</string>
|
||||
<string name="the_key_this_room_signs_as">The key this room signs as, and the ceremony it came from.</string>
|
||||
<string name="what_the_group_signed">What the group signed</string>
|
||||
<string name="this_room_signs_as">This room signs as</string>
|
||||
<string name="derivation_path">Derivation path</string>
|
||||
<string name="key_ceremony">Key ceremony</string>
|
||||
<string name="agreed_on">Agreed on</string>
|
||||
<string name="the_signed_event">The signed event</string>
|
||||
<string name="copy_the_signed_event">Copy the signed event</string>
|
||||
<string name="copied_the_signed_event">Copied the signed event</string>
|
||||
<string name="this_device_does_not_hold_the_event">This device holds the group's reading of this, but not the signed event itself. Nothing here can be checked against a signature.</string>
|
||||
<string name="sign">Sign</string>
|
||||
<string name="sign_in">Sign in</string>
|
||||
<string name="sign_in_is_not_yet_available_while_mantra_is">Sign in is not yet available while Mantra is in alpha testing.</string>
|
||||
|
||||
@@ -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<Participant> {
|
||||
return database.participantDao().findParticipantByPublicKey(publicKey)
|
||||
}
|
||||
|
||||
@@ -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<GroupSignedEvent>, 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<GroupSignedEvent>,
|
||||
chatRoomId: String
|
||||
): Pair<GroupSignedEvent, GroupKeyState>? =
|
||||
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.
|
||||
|
||||
@@ -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<Participant>
|
||||
|
||||
suspend fun getChatMessageListByChatRoomId(chatRoomId: String): List<LocalChatMessage>
|
||||
@@ -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<List<LocalChatRoom>> {
|
||||
return flow { }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,6 +78,8 @@ class ChatRoomDetailViewModel(
|
||||
localChatRoom = localChatRoom,
|
||||
artifacts = mantraRepository.getArtifacts(chatRoomId),
|
||||
dialects = mantraRepository.getDialects(chatRoomId),
|
||||
groupKeyState = chatRepository.groupKeyState(chatRoomId),
|
||||
signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MantraArtifact> = emptyList(),
|
||||
val dialects: List<MantraDialect> = 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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user