From a204279e2addaea11ec11f3d3b9f182c6a0019b4 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sat, 12 Sep 2026 15:23:33 +0200 Subject: [PATCH] feat(groups): the entries the group has accepted, under the schemas on its screen A section for the kind 31890s the group signed, read off GroupSignedEvent the way the profile, the posts, the relay lists and the schemas are. The reading gate is theirs -- the room as author, the room's real signature -- plus two of its own: an entry is matched to the list it replies to by the coordinate in its a root, and checked against that list's fields exactly as the queue checks a copy off a relay, so what this section calls accepted is what the queue will mark curated once the entry is broadcast and read back. Newest per coordinate, since re-curating is a newer event under the same d. Each card names the list, gives the queue row's glance of what the entry is, credits the suggester, and dates the group's decision; under it, the same broadcast button every signed event in the block has, gated on nothing. There is no way to add one here, because an entry is accepted from the queue on the schema's card above, and the empty state says so. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@732a4527c60aaa3702a5aebe7c90ded2a159b920 --- .../composeResources/values/strings.xml | 5 + .../repository/DatabaseChatRepository.kt | 16 + .../mantra/compose/nostr/GroupCuratedEntry.kt | 134 ++++++++ .../compose/repository/ChatRepository.kt | 18 + .../ui/composable/ChatRoomDetailScreen.kt | 145 +++++++- .../composable/CuratedSuggestionListScreen.kt | 7 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 1 + .../ui/view/state/ChatRoomDetailUIState.kt | 11 + .../compose/nostr/GroupCuratedEntryTest.kt | 317 ++++++++++++++++++ .../GroupNostrProfileSectionJvmTest.kt | 130 +++++++ 10 files changed, 777 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4644ca10..3651aa15 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -611,6 +611,11 @@ Must match %1$s. At least one of these is needed: %1$s. %1$s: %2$s + + Curated entries + This group hasn't accepted anything into a list yet. An entry a quorum signs is shown here, and reaches the list's readers once it is broadcast. + In %1$s + Suggested by %1$s Broadcast Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours. 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 40f4f4ef..fb511cec 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -10,8 +10,10 @@ import press.mantra.compose.managers.GroupKeyStateManager import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedSchemaEvent import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet @@ -183,6 +185,20 @@ class DatabaseChatRepository( emptyList() } + override suspend fun groupCuratedEntries(chatRoomId: String): List = try { + // The schemas first, because an entry is read against the list it + // replies to and there is nothing to read one by without them. + GroupCuratedEntry.newestPerEntryAmong( + signedEvents = database.groupSignedEventDao() + .getByChatRoomIdAndKind(chatRoomId, CuratedEntryEvent.CANONICAL_KIND), + chatRoomId = chatRoomId, + schemas = groupCuratedSchemas(chatRoomId), + ) + } catch (e: Throwable) { + logger.e("Error reading the curated entries of $chatRoomId", e) + emptyList() + } + override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { database.groupSignedEventDao().getById(id)?.takeIf { it.chatRoomId == chatRoomId } } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt new file mode 100644 index 00000000..04caf549 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedEntry.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import kotlin.time.Instant + +/** + * A canonical entry the group signed: a suggestion it accepted into one of + * the lists it curates, as the list's own word. + * + * Kind 31890 -- see [CuratedEntryEvent] -- authored by the room's own key, + * which is the one key a list's readers accept a canonical entry from. Where + * the schema is the group asking and the queue is what strangers answered, + * this is what the group *decided*: the entries the list stands behind, each + * one a quorum's signature over a suggestion, edited or not, with a pointer + * back at who suggested it. + * + * ### Read against the list it replies to + * + * An entry replies to a schema by coordinate, so the reading needs the + * group's schemas as well as its signed events: the one whose coordinate the + * entry's `a` root names is the list it belongs to, and the entry is then + * checked against that list's fields exactly as `CuratedSuggestion.queueOf` + * checks a copy that came back off a relay. The two readings agree by + * construction, so what this screen shows as accepted is what the queue will + * mark as curated once the entry has been broadcast and read back. + * + * An entry replying to a list this device holds no schema for is not shown: + * without the schema there are no fields to read it by, and no way to say + * which list it is in. + * + * ### One per coordinate + * + * The kind is addressable, so re-curating an entry -- to take in a + * suggester's edit, or to fix the group's own -- is a newer event under the + * same `d`, and the newest is the entry the way the newest schema is the + * list. The coordinate is `31890::` whatever list the entry replies + * to, so the same `d` accepted into two of the group's lists is one + * coordinate and the newer event replaces the older, on relays and here + * alike. An identifier is the entry's rather than the list's, which is the + * reference client's reading too: its `d` is an external id that names the + * same film in every list. + * + * ### Nothing here has reached a relay + * + * The same caveat as the schema, and here it is the one that matters: the + * queue screen marks a suggestion curated only from a copy read back off the + * list's relays, so until a member broadcasts an entry from this section the + * queue still shows it waiting. The broadcast button under each card is how + * it gets there. + */ +data class GroupCuratedEntry( + /** The group's statement, whole, with the signature that makes it one. */ + val signedEvent: GroupSignedEvent, + /** The list the entry is in, as the group last signed its schema. */ + val list: GroupCuratedSchema, + /** What the statement says, keyed by the list's fields, and where it came from. */ + val entry: CuratedEntry, +) { + /** The group's nostr identity, which for a derived room is also its id. */ + val publicKey: HexKey get() = signedEvent.publicKey + + /** When the group signed it, which is the order entries are read in. */ + val signedAt: Instant get() = signedEvent.createdAt + + /** The entry's `d`, which with the group's key is its coordinate. */ + val identifier: String get() = entry.identifier + + val title: String get() = entry.title + + /** `31890::`. Two events sharing one are versions of one entry. */ + fun coordinate(): String = entry.coordinate() + + companion object { + /** + * The reading of one signed event against the group's [schemas], or + * null when it is not one of these. + * + * Four ways to be null and they are all the same refusal: the wrong + * kind, a signature that does not check out as [chatRoomId]'s, a root + * naming no list this device holds, or an entry the list's own fields + * refuse. A caller gets an entry the group really signed into a list it + * really curates, or gets nothing. + */ + fun of( + signedEvent: GroupSignedEvent, + chatRoomId: String, + schemas: List, + ): GroupCuratedEntry? { + if (signedEvent.kind != CuratedEntryEvent.CANONICAL_KIND) return null + if (!signedEvent.verifies()) return null + + val list = schemas.firstOrNull { it.isRepliedToBy(signedEvent.tags) } ?: return null + + val entry = CuratedEntryEvent.canonical( + event = signedEvent.toEvent(), + schema = list.schema, + coordinate = list.coordinate(), + curator = list.publicKey, + ) ?: return null + + return GroupCuratedEntry(signedEvent = signedEvent, list = list, entry = entry) + } + + /** + * The newest version of every entry [chatRoomId] signed among + * [signedEvents], most recently signed first. + * + * Newest per coordinate because the kind is addressable and a + * re-curation is a newer event under the same `d`; newest first so that + * what the group last decided is at the top, and the id breaking every + * tie so that two devices reading the same events in different orders + * show the same thing. + */ + fun newestPerEntryAmong( + signedEvents: List, + chatRoomId: String, + schemas: List, + ): List = + signedEvents + .mapNotNull { of(it, chatRoomId, schemas) } + .groupBy { it.coordinate() } + .values + .map { versions -> + versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id })) + } + .sortedWith( + compareByDescending { it.signedAt } + .thenByDescending { it.signedEvent.id } + ) + } +} 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 686f8091..4f4533d1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.managers.MarmotGroupCreation import press.mantra.compose.managers.SubgroupManager import press.mantra.compose.nostr.GroupNostrProfile +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelayList @@ -125,6 +126,19 @@ interface ChatRepository { */ suspend fun groupCuratedSchemas(chatRoomId: String): List + /** + * The canonical entries this group signed -- the suggestions it accepted + * into the lists it curates -- one per coordinate, most recently signed + * first. + * + * Kind 31890 is addressable, so a re-curation is a newer event under the + * same identifier and the newest wins, the way a schema's edit does. Each + * is read against the schema it replies to, and an entry whose list this + * device holds no schema for is left out. Empty in a group that has + * accepted nothing, which is every group until a quorum signs an entry. + */ + suspend fun groupCuratedEntries(chatRoomId: String): List + /** * One event the group signed, as the group signed it, or null when this * device does not hold it or it was signed in some other room. @@ -328,6 +342,10 @@ interface ChatRepository { chatRoomId: String ): List = emptyList() + override suspend fun groupCuratedEntries( + chatRoomId: String + ): List = emptyList() + override suspend fun groupSignedEvent( chatRoomId: String, id: HexKey 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 65e4ea70..2540e326 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 @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons 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.CheckCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.DeleteForever @@ -91,6 +92,7 @@ import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfil import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute +import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -113,7 +115,11 @@ import mantra.composeapp.generated.resources.relays import mantra.composeapp.generated.resources.posts import mantra.composeapp.generated.resources.add_post import mantra.composeapp.generated.resources.a_reply +import mantra.composeapp.generated.resources.curated_entries import mantra.composeapp.generated.resources.curated_schemas +import mantra.composeapp.generated.resources.in_list +import mantra.composeapp.generated.resources.suggested_by +import mantra.composeapp.generated.resources.this_group_has_not_accepted_anything_into_a_list_yet import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet import mantra.composeapp.generated.resources.add_schema import mantra.composeapp.generated.resources.view_suggestions @@ -143,6 +149,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.managers.SubgroupManager +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost @@ -588,10 +595,8 @@ fun ChatRoomDetailScreen( // thing a group publishes for strangers to answer: // a post is the group speaking, a schema is the // group asking -- for entries to a list it will - // then curate, by quorum, under the same key. It - // closes the identity block because everything in - // that block is signed by that key and read by - // people who were never in the room. + // then curate, by quorum, under the same key. What + // it curated follows, in the section below. item { Text( text = stringResource(Res.string.curated_schemas), @@ -727,6 +732,57 @@ fun ChatRoomDetailScreen( } } + // Under the schemas, because these are what a + // schema is *for*: the schema asks, strangers + // answer on the queue, and these are the answers + // the group put its key to. They close the + // identity block because everything in it is + // signed by that key and read by people who were + // never in the room, and an accepted entry is the + // last thing in that chain. + item { + Text( + text = stringResource(Res.string.curated_entries), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.groupCuratedEntries.isEmpty()) { + item { + Text( + stringResource( + Res.string.this_group_has_not_accepted_anything_into_a_list_yet + ) + ) + } + } else { + items( + items = chatRoomDetailUIState.groupCuratedEntries, + key = { accepted -> accepted.signedEvent.id } + ) { accepted -> + // The card and its broadcast as one item, + // so the button stays under the entry it + // sends. No gate on either: reading what + // the group decided takes no share of the + // key, and neither does repeating its + // signature to a relay. There is no way + // in to add one here, because an entry + // is accepted from the queue it was + // suggested to, on the schema's card + // above. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedEntryCard(accepted = accepted) + + BroadcastButton( + onClick = { broadcast(accepted.signedEvent) } + ) + } + } + } + // The untyped way into everything above it. Each // section has a form that builds one kind of event; // this takes any of those kinds as JSON, pasted from @@ -1835,6 +1891,87 @@ private fun GroupCuratedSchemaCard( } } +/** + * One entry the group accepted into a list it curates, as the list's own word. + * + * The title first, because every list has one; then which list, since a group + * may curate several and an entry says nothing about its list on its face; + * then the glance the queue row gives -- the fields that say what the entry + * *is*, labelled as the schema labels them, and not the links -- and the body + * where the list has one. The last line credits the suggester, when there was + * one, and dates the group's decision, which is the fact a member reading the + * block came for. + * + * Not a way in to anything: the entry is revised from the queue it was + * suggested to, where the suggestion it came from and the "Curated" mark sit + * side by side, and the card's one action is the broadcast under it. + */ +@Composable +private fun GroupCuratedEntryCard(accepted: GroupCuratedEntry) { + val schema = accepted.list.schema + val entry = accepted.entry + + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + }, + headlineContent = { + Text( + text = accepted.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + Text( + text = stringResource(Res.string.in_list, schema.displayName()), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + glanceOf(entry, schema)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + bodyOf(entry, schema)?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = listOfNotNull( + entry.source?.let { stringResource(Res.string.suggested_by, it.pubKey.shortened()) }, + stringResource( + Res.string.signed_by_the_group_on, + accepted.signedAt.toFormattedTimeAndDateString() + ) + ).joinToString(" · "), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + /** * One subgroup, as the parent's record and this device's rooms together have it. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt index e126cc8a..06de3aa2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -418,9 +418,10 @@ private fun CuratedMark() { * The fields a form prompts for, less the title (the headline), the body (its * own line) and anything that is a link (the sheet). Labelled because a bare * "2014 · en" says nothing to somebody who does not know the list's fields, - * and this screen is driven by a schema it has never seen before. + * and this screen is driven by a schema it has never seen before. Shared with + * the group's screen, so an entry is described the same way on both. */ -private fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = +internal fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = schema.formFields() .filterNot { it.tag() == TITLE_TAG || it.tag() == CuratedFieldConfig.CONTENT_TAG } .filterNot { it.type == CuratedFieldType.Url || it.type == CuratedFieldType.Image } @@ -431,7 +432,7 @@ private fun glanceOf(entry: CuratedEntry, schema: CuratedSchema): String? = ?.joinToString(" · ") /** The value of the field that writes to the body, or null. */ -private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = +internal fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? = schema.fields.firstOrNull { it.tag() == CuratedFieldConfig.CONTENT_TAG }?.let { entry.value(it.name) } private const val TITLE_TAG = "title" 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 7844c5ce..ec1f9afc 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 @@ -92,6 +92,7 @@ class ChatRoomDetailViewModel( groupRelayLists = chatRepository.groupRelayLists(chatRoomId), groupPosts = chatRepository.groupPosts(chatRoomId), groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId), + groupCuratedEntries = chatRepository.groupCuratedEntries(chatRoomId), canEditNostrProfile = canSign, canAddSubgroup = canSign, ) 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 601d993a..efc4e4af 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 @@ -7,6 +7,7 @@ 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 +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost @@ -96,6 +97,16 @@ sealed interface ChatRoomDetailUIState { * proposes a schema. */ val groupCuratedSchemas: List = emptyList(), + /** + * The entries this group has accepted into the lists it curates, most + * recently signed first. + * + * Under the schemas because they are what a schema is *for*: the schema + * asks, strangers suggest, and these are what the group decided to + * stand behind. Empty in a group that has accepted nothing, which is + * every group until a quorum signs an entry. + */ + val groupCuratedEntries: List = emptyList(), /** * Whether this device could sign a profile for the group. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt new file mode 100644 index 00000000..e65a72db --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/GroupCuratedEntryTest.kt @@ -0,0 +1,317 @@ +package press.mantra.compose.nostr + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import fr.acinq.bitcoin.ByteVector +import fr.acinq.bitcoin.ByteVector32 +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.crypto.frost.Frost +import fr.acinq.bitcoin.crypto.frost.IndividualNonce +import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import fr.acinq.bitcoin.crypto.frost.SecretNonce +import fr.acinq.bitcoin.crypto.frost.Session +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.extensions.toHex +import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldConfig +import press.mantra.compose.nostr.curated.CuratedFieldType +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef +import press.mantra.compose.nostr.curated.CuratedVisibility +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * What may be shown as an entry the group accepted into a list it curates. + * + * The reading gate is `GroupCuratedSchemaTest`'s -- the room has to be the + * author and the signature has to be the room's -- plus the two things this + * reader alone decides. An entry is read against the list it replies to, so + * one replying to a list this device holds no schema for is not shown, and one + * the list's own fields refuse is not shown either: it is the check the queue + * makes on a relay's copy, made here on the group's own, so the two screens + * cannot disagree about what the group accepted. + * + * The ordering half is the schema's: one per coordinate, newest winning, since + * re-curating is a newer event under the same `d`. + */ +class GroupCuratedEntryTest { + private val participants = 3 + private val threshold = 2 + + private val groupMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("1c0ffee0000000000000000000000000000000000000000000000000000000a1") + ), + nParticipants = participants, + threshold = threshold + ) + + /** Another group entirely, for the entries signed by the wrong room. */ + private val strangerMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3decade0000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + + private val path = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH + + private val chatRoomId = + SharedKeyDerivation.marmotGroupId(groupMaterial.thresholdPublicKey.value.toHex(), path) + + private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + private val films = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle, with a link to watch each one.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year", config = CuratedFieldConfig(min = 1900, max = 2100)), + CuratedField( + name = "watchUrl", + type = CuratedFieldType.Url, + isRequired = true, + label = "Where to watch", + config = CuratedFieldConfig(tag = "r", marker = "watch", max = 500, https = true), + ), + ), + relays = listOf("wss://relay.example.com"), + ) + + private val books = films.copy(identifier = "books", name = "Books worth translating") + + /** The lists as the group's screen holds them: read off the group's real signatures. */ + private val schemas: List = listOf(films, books).map { schema -> + assertNotNull(GroupCuratedSchema.of(signed(CuratedSchemaEvent.template(schema, createdAt = 1_700_000_000)), chatRoomId)) + } + + private val filmsList = schemas.first { it.identifier == "films" } + private val booksList = schemas.first { it.identifier == "books" } + + private fun riseAndRise(year: String = "2014") = mapOf( + "identifier" to listOf("imdb:tt2821314"), + "title" to listOf("The Rise and Rise of Bitcoin"), + "year" to listOf(year), + "watchUrl" to listOf("https://bitcoindoc.com/"), + ) + + private val source = CuratedSuggestionRef( + id = "a".repeat(64), + coordinate = "31888:$suggester:imdb:tt2821314", + pubKey = suggester, + ) + + private fun entry( + list: GroupCuratedSchema = filmsList, + values: Map> = riseAndRise(), + createdAt: Long = 1_700_000_900, + ): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate( + schema = list.schema, + coordinate = list.coordinate(), + curator = list.publicKey, + values = values, + source = source, + createdAt = createdAt, + ) + + @Test + fun `an entry the room signed into a list it curates is one it accepted`() { + val accepted = assertNotNull(GroupCuratedEntry.of(signed(entry()), chatRoomId, schemas)) + + assertEquals("The Rise and Rise of Bitcoin", accepted.title) + assertEquals("imdb:tt2821314", accepted.identifier) + assertEquals("31890:$chatRoomId:imdb:tt2821314", accepted.coordinate()) + assertEquals(filmsList, accepted.list) + assertEquals("2014", accepted.entry.value("year")) + assertEquals(source, accepted.entry.source) + } + + @Test + fun `an entry another group signed is not this group's`() { + val stranger = GroupSignedEvent.fromEvent( + event = event(entry(), material = strangerMaterial), + chatRoomId = chatRoomId + ) + + assertNull(GroupCuratedEntry.of(stranger, chatRoomId, schemas)) + } + + @Test + fun `an entry the room did not sign is not one`() { + // Authored by the room and signed by somebody else: a member declaring + // the group accepted something, which the reading verifies rather than + // trusting the author field. + assertNull(GroupCuratedEntry.of(signed(entry(), signer = strangerMaterial), chatRoomId, schemas)) + + listOf("f".repeat(128), "not a signature", "").forEach { rubbish -> + assertNull( + GroupCuratedEntry.of(signed(entry(), signature = rubbish), chatRoomId, schemas), + "an entry signed with \"$rubbish\" was accepted" + ) + } + } + + @Test + fun `an event of another kind is not an entry`() { + assertNull( + GroupCuratedEntry.of(signed(entry()).copy(kind = CuratedEntryEvent.SUGGESTION_KIND), chatRoomId, schemas) + ) + } + + @Test + fun `an entry into a list this device holds no schema for is not shown`() { + // Really signed, and replying to a real list -- just not one among the + // schemas the reader was given. There are no fields to read it by. + assertNull(GroupCuratedEntry.of(signed(entry(list = booksList)), chatRoomId, schemas = listOf(filmsList))) + } + + @Test + fun `an entry the list's own fields refuse is not shown`() { + // The room's real signature over a year outside the schema's range: the + // group's statement, and still not an entry the list's readers would + // count. The queue would drop the relay's copy for the same reason. + val outOfRange = signed(entry(values = riseAndRise(year = "1850"))) + + assertNull(GroupCuratedEntry.of(outOfRange, chatRoomId, schemas)) + assertEquals(emptyList(), GroupCuratedEntry.newestPerEntryAmong(listOf(outOfRange), chatRoomId, schemas)) + } + + @Test + fun `the newest per coordinate wins, and two identifiers are two entries`() { + val first = signed(entry(createdAt = 1_700_000_900)) + val revised = signed(entry(values = riseAndRise(year = "2015"), createdAt = 1_700_001_800)) + val another = signed( + entry( + values = mapOf( + "identifier" to listOf("magic-money"), + "title" to listOf("Magic Money"), + "watchUrl" to listOf("https://example.com/watch"), + ), + createdAt = 1_700_001_200, + ) + ) + + listOf( + listOf(first, revised, another), + listOf(another, revised, first), + listOf(revised, another, first), + ).forEach { events -> + val accepted = GroupCuratedEntry.newestPerEntryAmong(events, chatRoomId, schemas) + + assertEquals( + listOf("imdb:tt2821314", "magic-money"), + accepted.map { it.identifier }, + "the entries came back in the wrong order, or one of them went missing" + ) + assertEquals("2015", accepted.first().entry.value("year"), "a re-curation did not replace the entry it revised") + } + } + + @Test + fun `the same identifier in two lists is one coordinate, and the newer decides`() { + val inFilms = signed(entry(list = filmsList, createdAt = 1_700_000_900)) + val inBooks = signed(entry(list = booksList, createdAt = 1_700_001_800)) + + val accepted = GroupCuratedEntry.newestPerEntryAmong(listOf(inFilms, inBooks), chatRoomId, schemas) + + assertEquals(1, accepted.size, "one coordinate was shown as two entries") + assertEquals(booksList, accepted.single().list) + } + + /** An entry as `FrostSigningManager.complete` files one. */ + private fun signed( + template: EventTemplate<*>, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): GroupSignedEvent = GroupSignedEvent.fromEvent( + event = event(template, material, signer, signature), + chatRoomId = chatRoomId, + derivationPath = SharedKeyDerivation.formatPath(path) + ) + + private fun event( + template: EventTemplate<*>, + material: KeyMaterial = groupMaterial, + signer: KeyMaterial = material, + signature: String? = null + ): Event { + val groupPubKey = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .hex + + val id = EventHasher.hashId( + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content + ) + + return Event( + id = id, + pubKey = groupPubKey, + createdAt = template.createdAt, + kind = template.kind, + tags = template.tags, + content = template.content, + sig = signature ?: groupSignature(signer, id) + ) + } + + /** + * A real FROST signature by [material]'s quorum over [eventId], through the + * same shape `FrostSigningManager.advance` runs. + */ + private fun groupSignature(material: KeyMaterial, eventId: String): String { + val cache = SharedKeyDerivation + .derive(material.thresholdPublicKey.value.toHex(), path) + .cache + val message = ByteVector(eventId.hexToByteArray()) + val signerIds = listOf(0, 1) + + val nonces = signerIds.map { signerId -> + SecretNonce.generate( + sessionRandom = ByteVector32("a".repeat(63) + "${signerId + 1}"), + secretShare = material.secretShares[signerId], + publicShare = material.publicShares[signerId], + tweakedThresholdPublicKey = cache.tweakedPublicKey, + message = message, + extraInput = null + ) + } + + val signingSession = Session.create( + aggregatedNonce = IndividualNonce.aggregate(nonces.map { it.second }).right!!, + signerIds = signerIds.map { it.toUInt() }, + signerPublicShares = signerIds.map { material.publicShares[it] }, + nParticipants = participants, + threshold = threshold, + tweakCache = cache, + message = message + ) + + val partials = signerIds.mapIndexed { position, signerId -> + signingSession.sign( + nonces[position].first, + material.secretShares[signerId], + signerId.toUInt() + ).right!! + } + + return signingSession.aggregateSigs(partials).right!!.toByteArray().toHex() + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt index 733dd80a..137aa291 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/GroupNostrProfileSectionJvmTest.kt @@ -16,15 +16,20 @@ 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.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.GroupCuratedEntry import press.mantra.compose.nostr.GroupCuratedSchema import press.mantra.compose.nostr.GroupNostrProfile import press.mantra.compose.nostr.GroupPost import press.mantra.compose.nostr.GroupRelay import press.mantra.compose.nostr.GroupRelayList import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedFieldType import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedSuggestionRef import press.mantra.compose.nostr.curated.CuratedVisibility import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import press.mantra.compose.repository.ChatRepository @@ -281,6 +286,79 @@ class GroupNostrProfileSectionJvmTest { assertTrue(books.top < second.top, "the second queue button is not under its card") } + @Test + fun `the curated entries sit under the schemas and above the group's work`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + entries = listOf( + entry("The Rise and Rise of Bitcoin", 1_700_000_900), + entry("Magic Money", 1_700_000_300, suggestedBy = null) + ) + ) + ) { + val schemas = onNodeWithText("Curated schemas").getBoundsInRoot().top + val entries = onNodeWithText("Curated entries").getBoundsInRoot().top + val subgroups = onNodeWithText("Subgroups").getBoundsInRoot().top + + assertTrue(schemas < entries, "the curated entries climbed above the schemas") + assertTrue(entries < subgroups, "the curated entries fell below the group's work") + + // Most recently signed first, one card per entry. + val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top + assertTrue(newest < oldest, "the entries were drawn oldest first") + + // Which list, what the entry is -- labelled as the schema labels it -- + // and who suggested it, where somebody did. + onAllNodesWithText("In Films worth translating").assertCountEquals(2) + onAllNodesWithText("Year: 2014", substring = true).assertCountEquals(2) + onAllNodesWithText("Suggested by eebb74ab · ", substring = true).assertCountEquals(1) + // Dated like every signed statement in the block: the profile, the + // schema, and the two entries. + onAllNodesWithText("Signed by the group on", substring = true).assertCountEquals(4) + } + + @Test + fun `a group that has accepted nothing says so, and offers no way to add one here`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)) + ) + ) { + onNodeWithText("Curated entries").assertIsDisplayed() + onNodeWithText("This group hasn't accepted anything into a list yet.", substring = true) + .assertIsDisplayed() + + // An entry is accepted from the queue on the schema's card, not from + // this section, so the section has no button of its own. + onNodeWithText("View suggestions").assertIsDisplayed() + onAllNodesWithText("Add entry").assertCountEquals(0) + } + + @Test + fun `every accepted entry offers a broadcast under its own card, share or no share`() = render( + state( + groupNostrProfile = profile, + schemas = listOf(schema("films", "Films worth translating", 1_700_000_000)), + entries = listOf(entry("The Rise and Rise of Bitcoin", 1_700_000_900)), + canEditNostrProfile = false + ) + ) { + // The profile's, the schema's and the entry's: the entry is the one + // event in the block whose broadcast the queue screen depends on, since + // that is where the "Curated" mark is read back from. + val buttons = onAllNodesWithText("Broadcast") + buttons.assertCountEquals(3) + + val schemaCard = onNodeWithText("Films worth translating").getBoundsInRoot().top + val entryCard = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val last = buttons[2].getBoundsInRoot().top + + assertTrue(schemaCard < entryCard, "the entry climbed above its schema") + assertTrue(entryCard < last, "the entry's broadcast is not under its card") + } + @Test fun `every signed event in the block offers a broadcast, under its own card`() = render( state( @@ -472,11 +550,62 @@ class GroupNostrProfileSectionJvmTest { ) ) + /** + * An entry the group accepted into the films list, built rather than read + * out of a signed event, for the reason [schema] is. The list's schema has a + * year, so the card has a glance to give. + */ + private fun entry( + title: String, + createdAt: Long, + suggestedBy: String? = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + ): GroupCuratedEntry { + val list = schema("films", "Films worth translating", 1_700_000_000).let { films -> + films.copy( + schema = films.schema.copy( + fields = films.schema.fields + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year") + ) + ) + } + val identifier = title.lowercase().replace(' ', '-') + + return GroupCuratedEntry( + signedEvent = GroupSignedEvent( + id = createdAt.toString().padStart(64, 'd'), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + list = list, + entry = CuratedEntry( + kind = CuratedEntryEvent.CANONICAL_KIND, + id = createdAt.toString().padStart(64, 'd'), + pubKey = chatRoomId, + createdAt = createdAt, + identifier = identifier, + title = title, + values = mapOf( + "identifier" to listOf(identifier), + "title" to listOf(title), + "year" to listOf("2014"), + ), + source = suggestedBy?.let { + CuratedSuggestionRef(id = "a".repeat(64), coordinate = "31888:$it:$identifier", pubKey = it) + } + ) + ) + } + private fun state( groupNostrProfile: GroupNostrProfile?, relayLists: List = GroupRelayList.allAmong(emptyList(), chatRoomId), posts: List = emptyList(), schemas: List = emptyList(), + entries: List = emptyList(), canEditNostrProfile: Boolean = true, mlsGroupState: String? = "state" ) = ChatRoomDetailUIState.Loaded( @@ -507,6 +636,7 @@ class GroupNostrProfileSectionJvmTest { groupRelayLists = relayLists, groupPosts = posts, groupCuratedSchemas = schemas, + groupCuratedEntries = entries, canEditNostrProfile = canEditNostrProfile )