diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7b11f466..0513166b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -1,6 +1,7 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent @@ -8,6 +9,9 @@ import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ArtifactVersionEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationArtifactVersionEvent import press.mantra.compose.nostr.nip30303.TranslationChapterEvent import press.mantra.compose.nostr.nip30303.TranslationChunkEvent @@ -31,7 +35,34 @@ object ProposedEvent { */ data class Summary(val label: String, val detail: String) - fun summarize(event: Event): Summary = when (event.kind) { + /** + * [chatRoomId] is the room the proposal is in, where one is known. + * + * Only one branch reads it, and only to leave something out: a project owned + * by the room it is in is the ordinary case, and saying "owned by this group" + * on every project trains a member not to read the line that matters when + * somebody else is on it. Null simply says everything. + * + * [resolve] turns a public key or an event id into something a person can + * read, or null when it cannot. It is synchronous on purpose -- this is + * called from composables as well as view models -- so a caller with a + * database behind it resolves what it needs first and passes a lookup into + * the map. + * + * The default resolves nothing, which is honest rather than ideal. A + * membership event carries two 64-character ids and nothing else, so without + * a resolver it can only be described by a truncated hash. That is tolerable + * inside the batch that founds a project, where the project's own line names + * what is happening and the memberships read as its continuation -- and in + * that batch the project id is unresolvable anyway, since nothing has + * applied it yet. It is not tolerable for a membership proposed on its own, + * which is the whole of what a member is being asked to agree to. + */ + fun summarize( + event: Event, + chatRoomId: String? = null, + resolve: (HexKey) -> String? = { null }, + ): Summary = when (event.kind) { DialectEvent.KIND -> Summary( label = "New dialect", detail = DialectEvent( @@ -125,6 +156,56 @@ object ProposedEvent { } ) + ProjectEvent.KIND -> Summary( + label = "New project", + detail = ProjectEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { project -> + listOfNotNull( + project.name(), + event.content.takeIf { it.isNotBlank() }, + // Shown because signing this is what makes it the group's + // statement that X started it. A member who cannot see the + // claim cannot be said to have agreed to it -- and nothing + // else checks it, since the creator signs nothing here. + project.creator()?.let { "started by ${describe(it, resolve)}" }, + // Only the owners who are not this room. See [chatRoomId]. + project.owners() + .filter { it != chatRoomId } + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") { describe(it, resolve) } + ?.let { "owned by $it" }, + ).joinToString(" · ") + } + ) + + ProjectArtifactVersionEvent.KIND -> Summary( + label = "A source version joins the project", + detail = ProjectArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + // The version first: in a founding batch the project is being + // signed two lines up and its id resolves to nothing, because + // nothing has applied it yet. + listOfNotNull( + membership.artifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown version" } + } + ) + + ProjectTranslationArtifactVersionEvent.KIND -> Summary( + label = "A translation joins the project", + detail = ProjectTranslationArtifactVersionEvent( + event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig + ).let { membership -> + listOfNotNull( + membership.translationArtifactVersionId()?.let { describe(it, resolve) }, + membership.projectId()?.let { resolve(it) }?.let { "to $it" }, + ).joinToString(" · ").ifBlank { "Unknown translation" } + } + ) + // The one thing a group signs that is about the group rather than about // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: @@ -157,4 +238,14 @@ object ProposedEvent { else -> Summary(label = "Event of kind ${event.kind}", detail = event.content) } + + /** + * [id] as a name if [resolve] has one, and as a short hash otherwise. + * + * Twelve characters because that is what the rest of the signing screens + * truncate to. A short hash is a poor thing to put in front of somebody + * deciding whether to sign, and saying so is the point: see [summarize]. + */ + private fun describe(id: HexKey, resolve: (HexKey) -> String?): String = + resolve(id) ?: id.take(12) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt index 99fe9a19..9c7e9318 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/BroadcastGroupSignedEventScreen.kt @@ -188,7 +188,10 @@ fun BroadcastGroupSignedEventScreen( // what goes on the wire, because the point of copying it is to // hand somebody something they can verify. val json = signedEvent.toEvent().toJson() - val summary = ProposedEvent.summarize(signedEvent.toEvent()) + val summary = ProposedEvent.summarize( + event = signedEvent.toEvent(), + chatRoomId = signedEvent.chatRoomId, + ) val canSend = !isPending diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index fbecea82..9ab44345 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -165,7 +165,11 @@ fun FrostSigningScreen( .padding(MaterialTheme.spacing.space250), verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200) ) { - WhatIsBeingSigned(proposed, state.items.size) + WhatIsBeingSigned( + events = proposed, + expected = state.items.size, + chatRoomId = state.localChatRoom.chatRoom.id, + ) HorizontalDivider() @@ -335,7 +339,7 @@ private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { * sign something they cannot check. */ @Composable -private fun WhatIsBeingSigned(events: List, expected: Int) { +private fun WhatIsBeingSigned(events: List, expected: Int, chatRoomId: String) { if (expected == 0 || events.size != expected) { Text( text = if (expected <= 1) { @@ -361,7 +365,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { events.forEachIndexed { index, event -> if (index > 0) HorizontalDivider() - OneThingBeingSigned(event) + OneThingBeingSigned(event, chatRoomId) } Text( @@ -376,8 +380,9 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { /** One event of the batch, described as the thing it is. */ @Composable -private fun OneThingBeingSigned(event: Event) { - val summary = ProposedEvent.summarize(event) +private fun OneThingBeingSigned(event: Event, chatRoomId: String) { + // The room, so a project owned by this group does not say so on every line. + val summary = ProposedEvent.summarize(event, chatRoomId = chatRoomId) Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 7ef1bdb0..78fd1923 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -228,7 +228,12 @@ class ChatMessageListViewModel( // something, and the notice should say what. lead = local.items.firstNotNullOfOrNull { item -> Event.fromJsonOrNull(item.unsignedEventJson) - ?.let { ProposedEvent.summarize(it) } + ?.let { + ProposedEvent.summarize( + event = it, + chatRoomId = localChatRoom.chatRoom.id, + ) + } }, eventCount = local.items.size ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt index 938d3d83..42e82e44 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ProposalListViewModel.kt @@ -86,7 +86,7 @@ class ProposalListViewModel( else -> local.proposer?.humanReadableNameOrPubkey() ?: local.session.coordinatorPublicKey.shortened() }, - signs = events.map { ProposedEvent.summarize(it) }, + signs = events.map { ProposedEvent.summarize(it, chatRoomId = chatRoomId) }, unreadable = items.size - events.size, awaitsYou = FrostSigningManager.isAwaitingApproval(local.session, items) ) diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 9ecddf7e..5992bab2 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -1,8 +1,12 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event +import press.mantra.compose.nostr.nip30303.ProjectArtifactVersionEvent +import press.mantra.compose.nostr.nip30303.ProjectEvent +import press.mantra.compose.nostr.nip30303.ProjectTranslationArtifactVersionEvent import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * How an event a group signs is said to the member signing it. @@ -23,6 +27,130 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + // --- projects --- + // + // A project says who started it and who owns it, and both are claims the + // group is being asked to put its signature to rather than facts anybody + // checks. The creator signs nothing; the owners sign nothing. So the only + // thing standing between "the group attested that X started this" and "the + // proposer typed X" is whether the member signing could see the claim. That + // is what these pin. + + private val roomId = "r".repeat(64) + private val creator = "c".repeat(64) + private val outsider = "d".repeat(64) + + private fun project(owners: Set) = ProjectEvent.build( + name = "Gospel of John", + description = "The four chapters.", + creatorPublicKey = creator, + ownerPublicKeys = owners, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + @Test + fun `a project names itself and whoever started it`() { + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertEquals("New project", summary.label) + assertTrue(summary.detail.contains("Gospel of John")) + assertTrue(summary.detail.contains("The four chapters.")) + assertTrue(summary.detail.contains("started by ${creator.take(12)}")) + } + + @Test + fun `a project owned by the room it is in says nothing about owners`() { + // The ordinary case. Saying "owned by this group" every time is how a + // member learns to stop reading the line. + val summary = ProposedEvent.summarize(project(setOf(roomId)), chatRoomId = roomId) + + assertTrue(!summary.detail.contains("owned by"), summary.detail) + } + + @Test + fun `a project owned by somebody else says who, and not the room`() { + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId + ) + + assertTrue(summary.detail.contains("owned by"), summary.detail) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + assertTrue(!summary.detail.contains(roomId.take(12)), summary.detail) + } + + @Test + fun `with no room to compare against, every owner is named`() { + val summary = ProposedEvent.summarize(project(setOf(roomId, outsider))) + + assertTrue(summary.detail.contains(roomId.take(12)), summary.detail) + assertTrue(summary.detail.contains(outsider.take(12)), summary.detail) + } + + @Test + fun `a resolver turns the keys into names`() { + val names = mapOf(creator to "Kgothatso", outsider to "Exonumia") + + val summary = ProposedEvent.summarize( + project(setOf(roomId, outsider)), + chatRoomId = roomId, + resolve = names::get + ) + + assertTrue(summary.detail.contains("started by Kgothatso"), summary.detail) + assertTrue(summary.detail.contains("owned by Exonumia"), summary.detail) + } + + @Test + fun `a membership is a hash without a resolver and a name with one`() { + val versionId = "f".repeat(64) + val projectId = "e".repeat(64) + + val source = ProjectArtifactVersionEvent.build( + projectId = projectId, + artifactVersionId = versionId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + // What a member sees in a founding batch, where the project is being + // signed on the line above and resolves to nothing because nothing has + // applied it yet. + val bare = ProposedEvent.summarize(source, chatRoomId = roomId) + assertEquals("A source version joins the project", bare.label) + assertEquals(versionId.take(12), bare.detail) + + // And what it has to look like before a membership is proposed on its + // own, which is the whole of what is being agreed to there. + val named = ProposedEvent.summarize( + source, + chatRoomId = roomId, + resolve = mapOf(versionId to "To Kill a Mockingbird 1.0", projectId to "Gospel of John")::get + ) + assertEquals("To Kill a Mockingbird 1.0 · to Gospel of John", named.detail) + } + + @Test + fun `a translation joining a project is named as a translation`() { + val translationId = "f".repeat(64) + + val membership = ProjectTranslationArtifactVersionEvent.build( + projectId = "e".repeat(64), + translationArtifactVersionId = translationId, + createdAt = 1_700_000_000, + ).let { + Event("a".repeat(64), roomId, it.createdAt, it.kind, it.tags, it.content, "") + } + + val summary = ProposedEvent.summarize(membership, chatRoomId = roomId) + + assertEquals("A translation joins the project", summary.label) + assertEquals(translationId.take(12), summary.detail) + } + private fun event( kind: Int, tags: Array> = emptyArray(),