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 )