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
)