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 <noreply@anthropic.com>
Pulled-From: curated/curated@732a4527c6
This commit is contained in:
Kgothatso Ngako
2026-09-12 15:23:33 +02:00
parent 8d32a69bf3
commit a204279e2a
10 changed files with 777 additions and 7 deletions

View File

@@ -611,6 +611,11 @@
<string name="must_match_the_pattern">Must match %1$s.</string>
<string name="at_least_one_of_these_is_needed">At least one of these is needed: %1$s.</string>
<string name="field_and_problem">%1$s: %2$s</string>
<!-- The group's screen: the entries it has accepted into the lists it curates. -->
<string name="curated_entries">Curated entries</string>
<string name="this_group_has_not_accepted_anything_into_a_list_yet">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.</string>
<string name="in_list">In %1$s</string>
<string name="suggested_by">Suggested by %1$s</string>
<!-- Broadcasting one event the group signed to relays a member picks. -->
<string name="broadcast">Broadcast</string>
<string name="sends_the_event_exactly_as_the_group_signed_it">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.</string>

View File

@@ -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<GroupCuratedEntry> = 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) {

View File

@@ -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:<group>:<d>` 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:<group>:<d>`. 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<GroupCuratedSchema>,
): 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<GroupSignedEvent>,
chatRoomId: String,
schemas: List<GroupCuratedSchema>,
): List<GroupCuratedEntry> =
signedEvents
.mapNotNull { of(it, chatRoomId, schemas) }
.groupBy { it.coordinate() }
.values
.map { versions ->
versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id }))
}
.sortedWith(
compareByDescending<GroupCuratedEntry> { it.signedAt }
.thenByDescending { it.signedEvent.id }
)
}
}

View File

@@ -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<GroupCuratedSchema>
/**
* 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<GroupCuratedEntry>
/**
* 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<GroupCuratedSchema> = emptyList()
override suspend fun groupCuratedEntries(
chatRoomId: String
): List<GroupCuratedEntry> = emptyList()
override suspend fun groupSignedEvent(
chatRoomId: String,
id: HexKey

View File

@@ -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.
*

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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<GroupCuratedSchema> = 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<GroupCuratedEntry> = emptyList(),
/**
* Whether this device could sign a profile for the group.
*

View File

@@ -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<GroupCuratedSchema> = 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<String, List<String>> = 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()
}
}

View File

@@ -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> = GroupRelayList.allAmong(emptyList(), chatRoomId),
posts: List<GroupPost> = emptyList(),
schemas: List<GroupCuratedSchema> = emptyList(),
entries: List<GroupCuratedEntry> = 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
)