diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1d34050d..f9412875 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -11,7 +11,7 @@ actual 334 strings sat inside composables. The launcher label is a separate resource, in androidMain/res/values/strings.xml, - which is what AndroidManifest.xml points at. It already said "Mantra". + which is what AndroidManifest.xml points at. Both now say "Mantra". Externalising the 334 is the rest of phase 4 in docs/material-design-conformance.md. They land here. @@ -249,7 +249,7 @@ The ceremony was abandoned. The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum. The group has a shared key. - The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. + The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone. This artifact has no version for a translation to be of. This artifact has no version for a chapter to attach to. @@ -570,6 +570,21 @@ Content must be text. The content isn't a profile. A kind 0 carries a JSON object of profile fields. This schema can't be signed as it is: + View suggestions + Suggestions + Everything anyone has suggested to this list, newest first. Only the group can take one up into the list. + 1 suggestion + %1$s suggestions + %1$s curated + Read from %1$s + Asking the list's relays… + Nobody has suggested anything to this list yet. + Ask again + Curated + Suggested by %1$s on %2$s + Taken up by the group into the list. + Copy the suggestion + Copied the suggestion A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec. An npub is a public key. Mantra needs the secret key, the nsec, to sign as you. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt index 61775857..33c48367 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrEventDao.kt @@ -77,6 +77,17 @@ interface NostrEventDao { query: RoomRawQuery ): List + /** + * The same query, watched. Observed tables named explicitly because a raw query cannot + * infer them: the events themselves, and the profiles joined onto them, so a row whose + * author's kind 0 arrives after the row does re-emits with the name filled in. + */ + @Transaction + @RawQuery(observedEntities = [NostrEvent::class, press.mantra.compose.database.model.Profile::class]) + fun observeNostrEventsMatchingFilter( + query: RoomRawQuery + ): Flow> + @Transaction @Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit") suspend fun getNostrEvents( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt index 007681a1..8b514f6b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/NostrEvent.kt @@ -478,6 +478,17 @@ data class NostrEvent( } } + /** The row as the event it was stored from, for readers written against the wire shape. */ + fun toEvent(): Event = Event( + id = id, + pubKey = pubKey, + createdAt = createdAt.epochSeconds, + kind = kind, + tags = tags, + content = content, + sig = sig, + ) + fun toTextNoteEvent(): TextNoteEvent { return TextNoteEvent( id = id, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt index baa14952..d9218c2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt @@ -738,6 +738,12 @@ class DatabaseNostrRepository( } } + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + return database.nostrEventDao().observeNostrEventsMatchingFilter( + NostrEventFilterQuery.build(synchronizationFilter) + ) + } + override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt new file mode 100644 index 00000000..67fcdb00 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/CuratedSuggestion.kt @@ -0,0 +1,115 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.extensions.shortened +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedEntryEvent +import kotlin.time.Instant + +/** + * One suggestion to a list the group curates, as somebody published it and as + * this device has read it. + * + * The schema is the group asking; these are the answers. Anyone may publish one + * -- that is the point of a list -- so unlike everything else on the group's + * screen a suggestion is not the group's word and was never signed in the room. + * It is a `NostrEvent` off a relay, signature-checked on the way in like every + * other, and then checked against the schema it replies to, because a relay is + * an untrusted place and an entry that does not satisfy the schema is not shown + * with the bad parts blanked out. See [CuratedEntryEvent]. + * + * ### The queue and the list + * + * The NIP's words: the canonical entries are the list, and the suggestions are + * its queue. This is the queue. A suggestion is marked [isCurated] when a + * canonical entry the *group* signed shares its identifier -- the coordinate + * both land on -- which is how a reader tells what the group has already taken + * up from what is still waiting. Only the group's own canonical entries count: + * curation is the one power a schema does not delegate, and a kind 31890 from + * anybody else is somebody else's list. + */ +data class CuratedSuggestion( + /** The entry, keyed by the schema's field names. */ + val entry: CuratedEntry, + /** The event as it was stored, for the id, the tags, and copying it somewhere. */ + val event: NostrEvent, + /** The suggester's profile where one has arrived, else null. */ + val suggester: Profile?, + /** Whether a canonical entry by the group shares this suggestion's identifier. */ + val isCurated: Boolean, +) { + val id: String get() = event.id + + val suggestedAt: Instant get() = event.createdAt + + /** + * What to call the suggester: their name where a profile has arrived and is + * a real one, else a short key. A placeholder profile is the one the indexer + * writes the moment a pubkey is first seen, and it is named "LOADING..." -- + * which is not a name. + */ + fun suggesterName(): String = + suggester?.takeUnless { it.createdAt == GENESIS_AT }?.humanReadableNameOrPubkey() + ?: entry.pubKey.shortened() + + companion object { + /** + * The queue of [schema], read off [events], newest first. + * + * Two readings over the same rows. Every kind 31888 that satisfies the + * schema and replies to its coordinate is a suggestion; every kind 31890 + * that does the same *and* is signed by the group is a canonical entry. + * Both kinds are addressable, so among events sharing a coordinate the + * newest is the entry and the rest are its history -- the client-side + * merge that realises an edit, since different relays hold different + * latest versions. Then the canonical identifiers mark the suggestions. + * + * Newest first because that is the order a queue is read in, and the id + * breaking every tie so two devices reading the same events in different + * orders show the same thing. + */ + fun queueOf(schema: GroupCuratedSchema, events: List): List { + val coordinate = schema.coordinate() + + val entries = events.mapNotNull { local -> + val event = local.nostrEvent.toEvent() + + when (event.kind) { + CuratedEntryEvent.SUGGESTION_KIND -> + CuratedEntryEvent.suggestion(event, schema.schema, coordinate) + CuratedEntryEvent.CANONICAL_KIND -> + CuratedEntryEvent.canonical(event, schema.schema, coordinate, curator = schema.publicKey) + else -> null + }?.let { entry -> local to entry } + } + .groupBy { (_, entry) -> entry.coordinate() } + .values + .map { versions -> + versions.maxWith(compareBy({ (_, entry) -> entry.createdAt }, { (_, entry) -> entry.id })) + } + + val curatedIdentifiers = entries + .filter { (_, entry) -> entry.isCanonical } + .map { (_, entry) -> entry.identifier } + .toSet() + + return entries + .filterNot { (_, entry) -> entry.isCanonical } + .map { (local, entry) -> + CuratedSuggestion( + entry = entry, + event = local.nostrEvent, + suggester = local.profile, + isCurated = entry.identifier in curatedIdentifiers, + ) + } + .sortedWith( + compareByDescending { it.entry.createdAt } + .thenByDescending { it.entry.id } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt index 40efc723..1d6727d8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/GroupCuratedSchema.kt @@ -1,6 +1,7 @@ package press.mantra.compose.nostr import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.nostr.curated.CuratedSchema import press.mantra.compose.nostr.curated.CuratedSchemaEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -34,6 +35,9 @@ import kotlin.time.Instant * one statement here that exists to be *replied to* by strangers, so until it is * published the list it defines has no queue -- the relays it names say where * suggestions would go, and nothing yet puts the schema there for them to find. + * The queue is read all the same, by `CuratedSuggestion.queueOf` over whatever + * those relays hold for [coordinate], so a schema that reached a relay by some + * other road has its suggestions shown here. */ data class GroupCuratedSchema( /** The group's statement, whole, with the signature that makes it one. */ @@ -53,7 +57,54 @@ data class GroupCuratedSchema( /** What a suggestion's `a` root names to reply to this list. */ fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier) + /** + * What to ask a relay for to read this list's queue: the NIP's two queries, + * as one request. + * + * Everything suggested to the list is whatever anyone published in reply + * to the coordinate; the list itself is what the *group* published in reply + * to it. The `authors` on the second is redundant with the rule that only + * the curator's canonical entries count -- `CuratedSuggestion.queueOf` + * enforces that on the way in regardless -- and is there to spare the relay + * the work. Both land in the same table, and [queueReadFilter] is how they + * are read back. + */ + fun queueFilters(): Array = arrayOf( + SynchronizationFilter( + kinds = arrayOf(schema.suggestionKind), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + limit = QUEUE_LIMIT, + ), + SynchronizationFilter( + kinds = arrayOf(CuratedSchemaEvent.CANONICAL_KIND), + authors = arrayOf(publicKey), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + limit = QUEUE_LIMIT, + ), + ) + + /** + * What to watch locally for the same rows: both kinds, replying to the + * coordinate. One filter rather than two because the local store is a + * single table and the reading tells the kinds apart itself. + */ + fun queueReadFilter(): SynchronizationFilter = SynchronizationFilter( + kinds = arrayOf(schema.suggestionKind, CuratedSchemaEvent.CANONICAL_KIND), + tags = mapOf(COORDINATE_TAG to listOf(coordinate())), + ) + companion object { + /** The tag a reply carries its schema's coordinate in. */ + private const val COORDINATE_TAG = "a" + + /** + * How much of a queue one request asks for: the reference client's + * window, and more than any list here is likely to have. NIP-01 scopes + * a limit to the stored events a relay sends before EOSE, so a longer + * queue is read on the next visit rather than never. + */ + private const val QUEUE_LIMIT = 500 + /** * The reading of one signed event, or null when it is not one of these. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt new file mode 100644 index 00000000..340239f4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEvent.kt @@ -0,0 +1,437 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind + +/** + * Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and + * as the curator accepted it. + * + * The two are one shape. A suggestion is a reply to a schema carrying one tag + * per field the schema defines; a canonical entry is the same tags again, signed + * by the curator, with an optional pointer back at the suggestion it came from. + * They are read by one reader and checked by one verifier, and only the kind, + * the signer and the pointer tell them apart -- which is what lets the list + * stand on its own if a suggester deletes their entry. + * + * This is the other half of [CuratedSchemaEvent]: that is what a list *asks* + * for, this is what it *gets*. Ported from the reference module's `verifyEntry` + * rule for rule, so that an entry bitcoin.mov accepts is one this app accepts + * and an entry it refuses is refused here for the same reason. + * + * ### Rejected, not repaired + * + * The NIP is explicit: a client MUST verify an entry against its schema and + * MUST reject one that does not satisfy it, rather than showing it with the + * offending parts blanked out. Relays carry malformed, partial and hostile + * events from other applications, and a half-parsed entry is worse than a + * missing one. So [suggestion] and [canonical] return the entry whole or return + * null, and [verifySuggestion] and [verifyCanonical] say why. + */ +object CuratedEntryEvent { + /** The kind anyone publishes a suggestion under. */ + const val SUGGESTION_KIND: Kind = CuratedSchemaEvent.SUGGESTION_KIND + + /** The kind the curator republishes an accepted suggestion as. */ + const val CANONICAL_KIND: Kind = CuratedSchemaEvent.CANONICAL_KIND + + private const val TAG_ADDRESS = "a" + private const val TAG_EVENT = "e" + private const val TAG_IDENTIFIER = "d" + private const val TAG_TITLE = "title" + + /** What every schema coordinate starts with, which is how a root is told from a source. */ + private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:" + + private val SUGGESTION_PREFIX = "$SUGGESTION_KIND:" + + /** + * The entry [event] carries, checked against [schema] as a suggestion to the + * list at [coordinate], or null when it is not one a client should show. + */ + fun suggestion(event: Event, schema: CuratedSchema, coordinate: String): CuratedEntry? = + if (verifySuggestion(event, schema, coordinate).isEmpty()) read(event, schema) else null + + /** + * The entry [event] carries, checked against [schema] as the list's own at + * [coordinate], or null. [curator] is the pubkey that signed the schema, and + * the only one this will accept an author of. + */ + fun canonical(event: Event, schema: CuratedSchema, coordinate: String, curator: HexKey): CuratedEntry? = + if (verifyCanonical(event, schema, coordinate, curator).isEmpty()) read(event, schema) else null + + /** + * The entry as the tags say it, whether or not it satisfies the schema. + * + * Split from [suggestion] and [canonical] for the same reason + * [CuratedSchemaEvent.read] is split from its parse: a reader that wants to + * show *why* -- a test, a form -- verifies separately. Everything here is + * keyed by the schema's field names rather than by tag, because a field is + * what a form and a screen know an entry by; the tag is where it was kept. + */ + fun read(event: Event, schema: CuratedSchema): CuratedEntry { + val values = schema.fields + .associate { field -> field.name to valuesOf(field, event.tags, event.content) } + .filterValues { it.isNotEmpty() } + + return CuratedEntry( + kind = event.kind, + id = event.id, + pubKey = event.pubKey, + createdAt = event.createdAt, + identifier = first(event.tags, TAG_IDENTIFIER)?.trim() ?: "", + title = first(event.tags, TAG_TITLE)?.trim() ?: "", + values = values, + source = if (event.kind == CANONICAL_KIND) source(event.tags) else null, + ) + } + + /** + * Every reason [event] is not a suggestion to the list at [coordinate], or + * nothing. + * + * The NIP's seven rules in the NIP's order: the kind is the suggestion kind; + * every required field is present; no field without `repeat` appears twice; + * every value matches its field's type and config; every `require-any` group + * is met; the `a` root names the schema; and the author is somebody the + * schema's visibility lets suggest. + */ + fun verifySuggestion(event: Event, schema: CuratedSchema, coordinate: String): List = + verifyEntry(event, schema, coordinate, expectedKind = schema.suggestionKind) + + /** + * Every reason [event] is not the list's own entry at [coordinate], or nothing. + * + * Everything [verifySuggestion] checks, for kind 31890, and two rules on top. + * The author must be [curator]: curation is the one power a schema does not + * delegate, and `visibility` has no bearing on it. And a source reference, + * when there is one, must be a well-formed suggestion coordinate -- a + * malformed one is worse than none, since it credits the wrong person or + * points at an entry nobody suggested. + */ + fun verifyCanonical( + event: Event, + schema: CuratedSchema, + coordinate: String, + curator: HexKey, + ): List = buildList { + addAll(verifyEntry(event, schema, coordinate, expectedKind = CANONICAL_KIND)) + + if (event.pubKey != curator) { + add(CuratedEntryProblem(FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)) + } + + val source = firstAddress(event.tags, SUGGESTION_PREFIX) + if (source != null && CuratedCoordinate.parse(source) == null) { + add(CuratedEntryProblem(FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)) + } + } + + /** + * One value against its field's type and config, or the first thing wrong + * with it. + * + * `max` is a bound on numeric types and a length on everything else, and a + * numeric field returns before the length check so the two never meet. + * A pattern the platform cannot compile counts as not matched: the schema + * asked for a check this reader cannot make, and an entry it cannot check + * is one it cannot vouch for. + */ + fun checkValue(field: CuratedField, value: String): CuratedEntryProblem.Reason? { + val trimmed = value.trim() + val config = field.config + + if (trimmed.isEmpty()) return CuratedEntryProblem.Reason.Empty + + when (field.type) { + CuratedFieldType.Text, CuratedFieldType.LongText -> Unit + + CuratedFieldType.Token -> + if (trimmed.any { it.isWhitespace() }) return CuratedEntryProblem.Reason.NotOneWord + + CuratedFieldType.Url -> + if (!isSafeUrl(trimmed, httpsOnly = config.https == true)) { + return if (config.https == true) { + CuratedEntryProblem.Reason.NotHttps + } else { + CuratedEntryProblem.Reason.NotAUrl + } + } + + CuratedFieldType.Image -> + if (!isSafeUrl(trimmed, httpsOnly = true)) return CuratedEntryProblem.Reason.NotHttps + + CuratedFieldType.Enum -> + if (config.options?.contains(trimmed) != true) return CuratedEntryProblem.Reason.NotAnOption + + CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> { + val number = trimmed.toWholeNumberOrNull() ?: return CuratedEntryProblem.Reason.NotAWholeNumber + if (field.type == CuratedFieldType.Duration && number <= 0) return CuratedEntryProblem.Reason.NotPositive + config.min?.let { if (number < it) return CuratedEntryProblem.Reason.BelowMinimum } + config.max?.let { if (number > it) return CuratedEntryProblem.Reason.AboveMaximum } + // A numeric `max` is a bound, not a length cap. + return null + } + } + + config.max?.let { if (trimmed.length > it) return CuratedEntryProblem.Reason.TooLong } + + config.pattern?.let { pattern -> + val whole = runCatching { Regex("^(?:$pattern)$") }.getOrNull() + if (whole == null || !whole.matches(trimmed)) return CuratedEntryProblem.Reason.PatternMismatch + } + + return null + } + + /** + * Every value [field] holds in an event: its tags, or the content body. + * + * A field with a marker matches only tags carrying that marker in third + * position; one without matches every tag of its name, whatever the third + * element says. Blank values are not values. + */ + fun valuesOf(field: CuratedField, tags: Array>, content: String): List { + val tag = field.tag() + if (tag == CuratedFieldConfig.CONTENT_TAG) { + return content.trim().takeIf { it.isNotEmpty() }?.let { listOf(it) } ?: emptyList() + } + + val marker = field.config.marker + return tags + .filter { it.size > 1 && it[0] == tag && it[1].isNotBlank() } + .filter { marker == null || it.getOrNull(2) == marker } + .map { it[1] } + } + + /** + * Where a canonical entry says it came from, or null for one nobody suggested. + * + * The coordinate is the `a` tag with the suggestion kind's prefix, which is + * what tells it from the schema root; the id is the first `e` tag, pinning + * the exact version the curator reviewed, and may be absent. + */ + fun source(tags: Array>): CuratedSuggestionRef? { + val address = firstAddress(tags, SUGGESTION_PREFIX) ?: return null + val coordinate = CuratedCoordinate.parse(address) ?: return null + + return CuratedSuggestionRef( + id = first(tags, TAG_EVENT)?.trim()?.ifEmpty { null }, + coordinate = address, + pubKey = coordinate.pubKey, + ) + } + + /** An http or https URL with a host, or with [httpsOnly] an https one. Nothing else reaches a link. */ + fun isSafeUrl(value: String, httpsOnly: Boolean = false): Boolean = + if (httpsOnly) CuratedSchemaEvent.isHttpsUrl(value) else HTTP_URL.matches(value.trim()) + + private val HTTP_URL = Regex("^https?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE) + + /** The problems a suggestion and a canonical entry share. */ + private fun verifyEntry( + event: Event, + schema: CuratedSchema, + coordinate: String, + expectedKind: Kind, + ): List = buildList { + if (event.kind != expectedKind) { + add(CuratedEntryProblem(FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)) + } + + // The root is required whenever the schema has a coordinate to reply to, + // which here is always: a group's schema is signed before anything can + // reply to it. Only schema coordinates count; an entry carries `a` tags + // for other reasons too, the source pointer among them. + val roots = event.tags + .filter { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(SCHEMA_PREFIX) } + .map { it[1] } + if (roots.isEmpty()) { + add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)) + } else if (coordinate !in roots) { + add(CuratedEntryProblem(FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)) + } + + for (field in schema.fields) { + val values = valuesOf(field, event.tags, event.content) + + if (values.isEmpty()) { + if (field.isRequired) add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Missing)) + continue + } + + if (values.size > 1 && field.config.repeat != true) { + add(CuratedEntryProblem(field.name, CuratedEntryProblem.Reason.Repeated)) + } + + // One problem per field is enough. + values.firstNotNullOfOrNull { checkValue(field, it) } + ?.let { add(CuratedEntryProblem(field.name, it)) } + } + + for (group in schema.requireAny) { + val satisfied = group.any { name -> + schema.fields.firstOrNull { it.name == name } + ?.let { valuesOf(it, event.tags, event.content).isNotEmpty() } + ?: false + } + if (!satisfied) { + add(CuratedEntryProblem(group.first(), CuratedEntryProblem.Reason.RequireAnyUnmet)) + } + } + + if (!schema.canSuggest(event.pubKey, coordinate)) { + add(CuratedEntryProblem(FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)) + } + } + + /** + * Whether [pubKey] may suggest to this list, at [coordinate]. + * + * Everyone on a public list; the curator and the `p` tags on the other two. + * The curator is read off the coordinate rather than passed, because a + * [CuratedSchema] does not know who signed it and the coordinate does. + */ + private fun CuratedSchema.canSuggest(pubKey: HexKey, coordinate: String): Boolean { + if (visibility == CuratedVisibility.Public) return true + if (CuratedCoordinate.parse(coordinate)?.pubKey == pubKey) return true + return pubKey in suggesters + } + + private fun first(tags: Array>, name: String): String? = + tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1) + + private fun firstAddress(tags: Array>, prefix: String): String? = + tags.firstOrNull { it.size > 1 && it[0] == TAG_ADDRESS && it[1].startsWith(prefix) }?.get(1) + + /** An optionally signed run of digits and nothing else, as the reference reads one. */ + private fun String.toWholeNumberOrNull(): Long? = + takeIf { WHOLE_NUMBER.matches(it) }?.toLongOrNull() + + private val WHOLE_NUMBER = Regex("^-?\\d+$") + + /** The names problems are filed under when they are about the entry rather than a field. */ + const val FIELD_KIND = "kind" + const val FIELD_SCHEMA = "schema" + const val FIELD_VISIBILITY = "visibility" + const val FIELD_CURATOR = "curator" + const val FIELD_SOURCE = "source" +} + +/** + * One entry in a curated list, read off a kind 31888 or 31890 event. + * + * [values] is keyed by the schema's field names, each holding every value the + * entry carries for that field in tag order -- more than one only for a field + * the schema lets repeat. A field the entry has nothing for is absent rather + * than empty. [identifier] and [title] are also in there under whatever the + * schema calls those fields; they are lifted out because every list has them + * and every screen wants them. + */ +data class CuratedEntry( + val kind: Kind, + val id: HexKey, + val pubKey: HexKey, + /** Unix seconds, as the event carries it. */ + val createdAt: Long, + /** The `d` tag, which with [kind] and [pubKey] is the entry's coordinate. */ + val identifier: String, + val title: String, + val values: Map>, + /** For a canonical entry, the suggestion it came from, or null when nobody suggested it. */ + val source: CuratedSuggestionRef? = null, +) { + val isCanonical: Boolean get() = kind == CuratedEntryEvent.CANONICAL_KIND + + /** `kind:pubkey:d`. Two events sharing one are versions of one entry. */ + fun coordinate(): String = CuratedCoordinate(kind, pubKey, identifier).toValue() + + /** The first value of [fieldName], or null when the entry has none. */ + fun value(fieldName: String): String? = values[fieldName]?.firstOrNull() +} + +/** Where a canonical entry came from: the suggestion the curator accepted. */ +data class CuratedSuggestionRef( + /** The exact version curated, or null when the curator did not pin one. */ + val id: HexKey?, + /** `31888::`, which follows the suggester's later edits. */ + val coordinate: String, + /** The suggester, credited. */ + val pubKey: HexKey, +) + +/** + * One reason an entry does not satisfy its schema: which field, and what about + * it. [field] is the field's name, or one of the [CuratedEntryEvent] `FIELD_` + * names for a rule about the entry as a whole; a `require-any` group is filed + * under its first field, so a form can show it inline. + */ +data class CuratedEntryProblem(val field: String, val reason: Reason) { + enum class Reason { + /** Not the kind the schema said suggestions come as, or not the canonical kind. */ + WrongKind, + + /** No `a` root naming any schema. */ + NotAReply, + + /** An `a` root naming some other schema. */ + RepliesElsewhere, + + /** A required field with no value. */ + Missing, + + /** A field without `repeat` with more than one. */ + Repeated, + + Empty, + NotOneWord, + NotAUrl, + NotHttps, + NotAnOption, + NotAWholeNumber, + NotPositive, + BelowMinimum, + AboveMaximum, + TooLong, + PatternMismatch, + + /** None of a `require-any` group's fields is present. */ + RequireAnyUnmet, + + /** The author may not suggest to a closed or private list. */ + NotPermitted, + + /** A canonical entry signed by somebody other than the schema's author. */ + NotTheCurator, + + /** A source pointer that is not a suggestion coordinate. */ + SourceMalformed, + } +} + +/** + * An addressable coordinate, `::`, split the one way the NIP + * allows: on the first two colons only. A `d` may itself contain colons -- + * `imdb:tt2821314` does -- so splitting on every one mangles the identifier and + * miscounts the parts. + */ +data class CuratedCoordinate(val kind: Kind, val pubKey: HexKey, val identifier: String) { + fun toValue(): String = "$kind:$pubKey:$identifier" + + companion object { + private val SHAPE = Regex("^(\\d{1,5}):([0-9a-fA-F]{64}):(.+)$") + + /** The three parts, or null for anything that is not a coordinate with a non-empty `d`. */ + fun parse(value: String): CuratedCoordinate? { + val match = SHAPE.matchEntire(value.trim()) ?: return null + val (kind, pubKey, identifier) = match.destructured + + return CuratedCoordinate( + kind = kind.toInt(), + pubKey = pubKey.lowercase(), + identifier = identifier, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt index 2ea083e5..cc6f7003 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/NostrRepository.kt @@ -175,6 +175,16 @@ interface NostrRepository { suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow> + /** + * Every stored event matching [synchronizationFilter], newest first, for as long as + * the caller collects. + * + * The filter is applied whole -- see `NostrEventFilterQuery` -- where [observeNostrFeed] + * recognises a handful of shapes and falls back to text notes for the rest. A reader + * that queued the same filter against a relay watches the answer arrive through this. + */ + suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> + suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean = true @@ -385,6 +395,10 @@ interface NostrRepository { TODO("Not yet implemented") } + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + TODO("Not yet implemented") + } + override suspend fun getNegentropicNostrFeedIds( synchronizationFilters: Array, applyLimits: Boolean 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 161f8bf7..0f2483a7 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 @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.Ballot import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Campaign import androidx.compose.material.icons.filled.DataObject @@ -79,6 +80,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute @@ -109,6 +111,7 @@ import mantra.composeapp.generated.resources.a_reply import mantra.composeapp.generated.resources.curated_schemas 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 import mantra.composeapp.generated.resources.fields_colon import mantra.composeapp.generated.resources.propose_event import mantra.composeapp.generated.resources.posted_on @@ -561,18 +564,48 @@ fun ChatRoomDetailScreen( items = chatRoomDetailUIState.groupCuratedSchemas, key = { curated -> curated.signedEvent.id } ) { curated -> - GroupCuratedSchemaCard( - curatedSchema = curated, - // Into the editor for a member who - // could propose a revision, and a plain - // card for one who could not: the same - // gate as every button in this block, - // applied to the card because each - // schema is edited on its own. - onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { - { + // The card and its queue as one item, so + // the button stays under the schema it is + // about however the list is arranged. + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + GroupCuratedSchemaCard( + curatedSchema = curated, + // Into the editor for a member who + // could propose a revision, and a plain + // card for one who could not: the same + // gate as every button in this block, + // applied to the card because each + // schema is edited on its own. + onEdit = if (chatRoomDetailUIState.canEditNostrProfile) { + { + onNavigateToRoute.invoke( + EditGroupCuratedSchemaRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint, + identifier = curated.identifier + ) + ) + } + } else { + null + } + ) + + // Under every schema and behind no + // gate, because the queue is the one + // thing here the group did not write: + // a schema exists to be answered by + // strangers, and reading the answers + // takes no share of the key. Per card + // because each list has its own queue. + TextButton( + onClick = { onNavigateToRoute.invoke( - EditGroupCuratedSchemaRoute( + CuratedSuggestionListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId, relayHint = relayHint, @@ -580,10 +613,21 @@ fun ChatRoomDetailScreen( ) ) } - } else { - null + ) { + Icon( + Icons.Default.Inbox, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.space125 + ) + ) + + Text(stringResource(Res.string.view_suggestions)) } - ) + } } } 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 new file mode 100644 index 00000000..b9378833 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreen.kt @@ -0,0 +1,690 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.ask_again +import mantra.composeapp.generated.resources.asking_the_list_s_relays +import mantra.composeapp.generated.resources.copied_the_suggestion +import mantra.composeapp.generated.resources.copy_the_suggestion +import mantra.composeapp.generated.resources.curated +import mantra.composeapp.generated.resources.everything_anyone_has_suggested_to_this_list +import mantra.composeapp.generated.resources.n_curated +import mantra.composeapp.generated.resources.n_suggestions +import mantra.composeapp.generated.resources.nobody_has_suggested_anything_to_this_list_yet +import mantra.composeapp.generated.resources.one_suggestion +import mantra.composeapp.generated.resources.read_from_relays +import mantra.composeapp.generated.resources.suggested_by_on +import mantra.composeapp.generated.resources.suggestions +import mantra.composeapp.generated.resources.taken_up_by_the_group_into_the_list +import mantra.composeapp.generated.resources.the_signed_event +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +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.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar +import press.mantra.compose.ui.composable.widgets.rememberNotifier +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.CuratedSuggestionListViewModel +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.time.Instant + +/** + * Everything anyone has suggested to one list the group curates. + * + * The group's screen shows the schema: what the list asks for. This is what it + * has been answered with -- one row per suggestion, newest first, whoever + * signed it -- which is the view a member wants before the group takes + * anything up: to see what is waiting, and what has already been taken. + * + * Read from the relays the schema names, and the header says which, because a + * queue read from the wrong relays and a list nobody has written to look the + * same from here. A suggestion that does not satisfy the schema is not on this + * screen at all; see `CuratedSuggestion`. + * + * A row opens a sheet with the whole entry, field by field, and the event it + * came as -- the thing a member can hand to somebody else to check, and the + * thing a canonical entry is made from. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CuratedSuggestionListScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + relayHint: String?, + identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = + CuratedSuggestionListUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + onNavigateBack: () -> Unit, +) { + val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel( + factory = CuratedSuggestionListViewModel.factory( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + identifier = identifier, + initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + ) + + LaunchedEffect(true) { + if (initialCuratedSuggestionListUIState == CuratedSuggestionListUIState.Loading) { + curatedSuggestionListViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.suggestions), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + ScreenStateTransition(curatedSuggestionListViewModel.curatedSuggestionListUIState) { uiState -> + when (val state = uiState) { + is CuratedSuggestionListUIState.Error -> { + // Nothing to retry: the room and the identifier came from a + // navigation argument, and reading them again with the same + // ones fails the same way. + ErrorState( + modifier = Modifier.padding(padding), + message = state.message, + onRetry = null + ) + } + + CuratedSuggestionListUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.screenMargin), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap) + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap)) + + LoadingDataIndicator(fillScreen = false) + } + } + + is CuratedSuggestionListUIState.Loaded -> { + // The id rather than the row, so the sheet follows an edit + // that replaces the row under it, and survives a rotation. + var openSuggestionId by rememberSaveable { mutableStateOf(null) } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + CuratedQueueHeader(state = state) + } + + if (state.suggestions.isEmpty()) { + item { + if (state.isAsking) { + LoadingDataIndicator( + fillScreen = false, + text = stringResource(Res.string.asking_the_list_s_relays) + ) + } else { + // Empty is a fact about the relays that were + // asked, and asking again is the one thing a + // member can do about it from here. + EmptyState( + message = stringResource( + Res.string.nobody_has_suggested_anything_to_this_list_yet + ), + action = { + TextButton( + onClick = { curatedSuggestionListViewModel.askAgain() } + ) { + Text(stringResource(Res.string.ask_again)) + } + } + ) + } + } + } else { + items( + items = state.suggestions, + key = { suggestion -> suggestion.id } + ) { suggestion -> + CuratedSuggestionCard( + suggestion = suggestion, + schema = state.schema.schema, + onOpen = { openSuggestionId = suggestion.id } + ) + } + } + } + + openSuggestionId + ?.let { id -> state.suggestions.firstOrNull { it.id == id } } + ?.let { suggestion -> + CuratedSuggestionSheet( + suggestion = suggestion, + schema = state.schema.schema, + onDismiss = { openSuggestionId = null } + ) + } + } + } + } + } +} + +/** + * What the queue is, in numbers, and where it was read from. + * + * The list is named the way its card on the group's screen names it -- the + * domain where it claims one, else the name -- so a member arriving from that + * card finds the same word. The counts are the reference client's line: how + * many are waiting, and how many of those the group has already taken up. + */ +@Composable +private fun CuratedQueueHeader(state: CuratedSuggestionListUIState.Loaded) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { + Text( + text = state.schema.schema.displayName(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(Res.string.everything_anyone_has_suggested_to_this_list), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = listOfNotNull( + when (state.suggestions.size) { + 1 -> stringResource(Res.string.one_suggestion) + else -> stringResource(Res.string.n_suggestions, state.suggestions.size) + }, + state.curatedCount.takeIf { it > 0 }?.let { stringResource(Res.string.n_curated, it) } + ).joinToString(" · "), + style = MaterialTheme.typography.labelMedium + ) + + if (state.relays.isNotEmpty()) { + Text( + text = stringResource( + Res.string.read_from_relays, + state.relays.joinToString { relay -> + RelayUrlNormalizer.normalizeOrNull(relay)?.displayUrl() ?: relay + } + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * One suggestion, as a row somebody is scanning. + * + * The title, because every list has one; a glance at the fields that say what + * the entry *is* -- the type, the year, the director -- and not the ones that + * say where to find it, since a URL on a row is a line of noise and the sheet + * has them; the body where the list has one; and who suggested it, when. The + * avatar is the suggester's, tinted from their key like every avatar here, so + * two suggestions by one stranger read as one stranger. + * + * "Curated" on the row is the one thing on it a member might act on: it says + * the group already took this up, and re-curating it would revise the entry + * rather than add one. + */ +@Composable +private fun CuratedSuggestionCard( + suggestion: CuratedSuggestion, + schema: CuratedSchema, + onOpen: () -> Unit +) { + val entry = suggestion.entry + + Card(modifier = Modifier.fillMaxWidth(), onClick = onOpen) { + ListItem( + leadingContent = { + ProfileAvatar(publicKey = entry.pubKey, profile = suggestion.suggester) + }, + trailingContent = if (suggestion.isCurated) { + { CuratedMark() } + } else { + null + }, + headlineContent = { + Text( + text = entry.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) + ) { + glanceOf(entry, schema)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + bodyOf(entry, schema)?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +@Composable +private fun CuratedMark() { + Row(verticalAlignment = Alignment.CenterVertically) { + // The word beside it says it, so the icon says nothing twice. + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.curated), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } +} + +/** + * The fields worth a glance on a row, as `Label: value`, or null when the entry + * has none of them. + * + * 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. + */ +private 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 } + .mapNotNull { field -> + entry.values[field.name]?.let { values -> "${field.labelOrName()}: ${values.joinToString()}" } + } + .takeIf { it.isNotEmpty() } + ?.joinToString(" · ") + +/** The value of the field that writes to the body, or null. */ +private 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" + +/** + * The whole of one suggestion, and a way to take it somewhere else. + * + * Every field the entry has a value for, labelled as the schema labels it, so + * the sheet reads as the form would have -- and then the event itself, which + * is the only part that can be checked, and the part a canonical entry is + * built from. Byte for byte, the way the key state sheet shows the group's own + * event, and for the same reason: a prettier rendering is a different string + * to the one whose id was hashed. + */ +@Composable +private fun CuratedSuggestionSheet( + suggestion: CuratedSuggestion, + schema: CuratedSchema, + onDismiss: () -> Unit +) { + ModalBottomSheet(onDismiss = onDismiss) { + CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema) + } +} + +/** + * The sheet's body, apart from the sheet. Split out so it can be rendered and + * measured on its own, since a bottom sheet is a popup a layout test cannot + * reach into. + */ +@Composable +internal fun CuratedSuggestionSheetContent( + suggestion: CuratedSuggestion, + schema: CuratedSchema +) { + val clipboardManager = LocalClipboardManager.current + + // Read out here rather than in the click handler: both of these are + // composable, and an onClick lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val copied = stringResource(Res.string.copied_the_suggestion) + + val entry = suggestion.entry + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource( + Res.string.suggested_by_on, + suggestion.suggesterName(), + suggestion.suggestedAt.toFormattedTimeAndDateString() + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (suggestion.isCurated) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = Decorative, + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap)) + + Text( + text = stringResource(Res.string.taken_up_by_the_group_into_the_list), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + // In the schema's order, which is the order the form asked in, and + // only the fields the entry answered: a form's empty inputs are not + // worth a line each here. The title is the heading and is not said twice. + schema.fields.filterNot { it.tag() == TITLE_TAG }.forEach { field -> + entry.values[field.name]?.let { values -> + CuratedSuggestionField(field = field, values = values) + } + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap)) + + Text( + text = stringResource(Res.string.the_signed_event), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.relatedGap)) + + val json = suggestion.event.toEvent().toJson() + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = json, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding) + ) + } + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) + + FilledTonalButton( + onClick = { + clipboardManager.setText(AnnotatedString(json)) + notify(copied) + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(Icons.Default.ContentCopy, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.copy_the_suggestion)) + } + } +} + +/** + * One field of an entry: the schema's label over the value, or the values one + * under another for a field that repeats. Monospaced where the value is a + * thing rather than prose -- a token, a link -- because those are compared and + * copied rather than read. + */ +@Composable +private fun CuratedSuggestionField(field: CuratedField, values: List) { + val isMonospaced = when (field.type) { + CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true + else -> false + } + + Text( + text = field.labelOrName(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = values.joinToString("\n"), + style = MaterialTheme.typography.bodyMedium, + fontFamily = if (isMonospaced) FontFamily.Monospace else null, + // Stated rather than inherited: the label above is deliberately quieter, + // and the value is the thing that was come for. + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap)) +} + +@ConformancePreviews +@Composable +private fun CuratedSuggestionListScreenPreview() { + val chatRoomId = "d4c3b2a1".repeat(8) + val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + + val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), + CuratedField( + "description", + CuratedFieldType.LongText, + isRequired = false, + label = "Why", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + fun suggestion(title: String, createdAt: Long, isCurated: Boolean) = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = createdAt.toString().padStart(64, 'a'), + pubKey = suggester, + createdAt = createdAt, + identifier = title.lowercase().replace(' ', '-'), + title = title, + values = mapOf( + "title" to listOf(title), + "year" to listOf("2014"), + "type" to listOf("documentary"), + "description" to listOf("Follows the early Bitcoin community from 2011 onward."), + ) + ), + event = NostrEvent( + id = createdAt.toString().padStart(64, 'a'), + pubKey = suggester, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + suggester = null, + isCurated = isCurated + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + CuratedSuggestionListScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = "u".repeat(64), + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + relays = listOf("wss://relay.one.example"), + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false), + ) + ), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateBack = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 3e0753a9..560951b9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen import press.mantra.compose.ui.composable.AddGroupPostScreen import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen +import press.mantra.compose.ui.composable.CuratedSuggestionListScreen import press.mantra.compose.ui.composable.ProposeGroupEventScreen import press.mantra.compose.ui.composable.EditGroupRelaysScreen import press.mantra.compose.ui.composable.HomeScreen @@ -132,6 +133,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute +import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute @@ -1112,6 +1114,21 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + CuratedSuggestionListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + identifier = route.identifier, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt new file mode 100644 index 00000000..9659aecb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/CuratedSuggestionListRoute.kt @@ -0,0 +1,19 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * The queue of one list the group curates: everything anyone has suggested to + * the schema at [identifier], read off the relays the schema names. + * + * Named by the schema's `d` for the reason `EditGroupCuratedSchemaRoute` is: + * the coordinate is what every suggestion replies to, and the group re-signing + * the schema changes the event without changing the queue. + */ +@Serializable +data class CuratedSuggestionListRoute( + val activeUserPublicKey: String, + val chatRoomId: String, // TODO: have this as a publicKey + val relayHint: String?, + val identifier: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt new file mode 100644 index 00000000..cc981945 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModel.kt @@ -0,0 +1,257 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.time.Duration.Companion.seconds + +/** + * The queue of one list the group curates, read off the relays the schema + * names. + * + * A pull, the way every one-off read in this app is: a request row per relay + * for the sync pump to drain, and a watch on the local table for what comes + * back. Nothing here talks to a relay itself. What the screen shows is + * therefore what this device has *stored* for the coordinate, which is also + * what it shows with no network at all -- and what it showed last time, until + * the relays answer again. + * + * ### Where the queue is read from + * + * The schema's `relay` tags, first. They are signed into the schema for exactly + * this: a client that finds the list anywhere knows where its replies live and + * cannot be sent elsewhere by an unsigned config. A schema that names none -- + * the NIP allows it and says a client may then pick its own -- is read from + * the group's general relay list, which is where the group said it publishes, + * and failing that from this build's default. The screen says which. + * + * ### What "still looking" means + * + * The pump marks a request sent when it goes out and processed when an event + * comes back, and a relay holding nothing for the coordinate answers with an + * EOSE the pump does not record. So there is no signal for "asked and empty", + * and the screen would otherwise call every list empty for the second before + * its rows arrived. [ASK_TIMEOUT] stands in: the queue is being asked until + * something lands or the wait runs out, and after that an empty queue is + * empty. The reference client does the same with a six-second timer. + */ +class CuratedSuggestionListViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + val relayHint: String?, + /** The `d` of the schema whose queue this is. */ + val identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState, + val nostrRepository: NostrRepository, + val chatRepository: ChatRepository, +): ViewModel() { + + var curatedSuggestionListUIState: CuratedSuggestionListUIState by mutableStateOf( + initialCuratedSuggestionListUIState + ) + private set + + private val logger = Logger.withTag(TAG) + + /** + * Serialises the read-modify-write on the state. The watch, the timer and + * a re-ask each update one field of it from their own coroutine, and a copy + * taken by one and written after another's is a lost update. + */ + private val stateMutex = Mutex() + + private var askTimeout: Job? = null + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find the group") + return@launch + } + + // Opened on a schema this device does not hold: nothing to ask for, + // since the coordinate is the group's key and the identifier and + // half of that came from a screen that no longer agrees. + val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier } + if (schema == null) { + curatedSuggestionListUIState = CuratedSuggestionListUIState.Error("Couldn't find that schema") + return@launch + } + + val relays = relaysToAsk( + schema = schema, + general = chatRepository.groupRelayLists(chatRoomId).firstOrNull { it.set == GroupRelaySet.General }, + ) + + curatedSuggestionListUIState = CuratedSuggestionListUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema, + relays = relays, + isAsking = true, + ) + + ask(schema, relays) + watch(schema) + } + } + + /** + * Asks the relays again, for a member who believes something is missing. + * + * The one thing the watch does not do on its own: it shows what arrives, + * and this is how more is made to arrive. Idempotent on the stored rows -- + * an event already held is upserted, not duplicated. + */ + fun askAgain() { + val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return + + viewModelScope.launch(Dispatchers.IO) { + update { it.copy(isAsking = true) } + ask(loaded.schema, loaded.relays) + } + } + + private suspend fun ask(schema: GroupCuratedSchema, relays: List) { + logger.d("Asking ${relays.size} relay(s) for the queue of ${schema.coordinate()}") + + nostrRepository.queueSynchronizeNostrEvent(requestsFor(schema, relays)) + + askTimeout?.cancel() + askTimeout = viewModelScope.launch(Dispatchers.IO) { + delay(ASK_TIMEOUT) + update { it.copy(isAsking = false) } + } + } + + /** + * Watches the stored rows for the coordinate for as long as the screen + * lives, re-reading the queue whole on every change. Whole rather than + * incrementally because the reading is a merge -- newest per coordinate, + * curated by identifier -- and a merge over a delta is a different reading. + */ + private suspend fun watch(schema: GroupCuratedSchema) { + nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()) + .distinctUntilChanged() + .collect { events -> + val queue = CuratedSuggestion.queueOf(schema, events) + logger.d("${queue.size} suggestion(s) to ${schema.coordinate()} among ${events.size} stored event(s)") + + update { loaded -> withQueue(loaded, queue) } + } + } + + private suspend fun update( + change: (CuratedSuggestionListUIState.Loaded) -> CuratedSuggestionListUIState.Loaded + ) { + stateMutex.withLock { + val loaded = curatedSuggestionListUIState as? CuratedSuggestionListUIState.Loaded ?: return + curatedSuggestionListUIState = change(loaded) + } + } + + companion object { + private const val TAG = "CuratedSuggestionListViewModel" + + /** + * The schema's relays, else the group's, else the default. See the + * class note for why in that order. Normalised so that two spellings + * of one relay are one request, and only the group's *read* relays, + * since this is a read. + * + * [general] is the group's general relay list as the screen has it, or + * null in a room that has none of that to say. An agreed-but-empty + * list falls through to the default rather than asking nobody: the + * group withdrew where it publishes, not where its lists are read. + */ + internal fun relaysToAsk(schema: GroupCuratedSchema, general: GroupRelayList?): List { + val named = schema.schema.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it)?.url }.distinct() + if (named.isNotEmpty()) return named + + val relays = general?.takeIf { it.isAgreed }?.relays ?: GroupRelaySet.General.defaults() + + return relays.filter { it.read }.map { it.url.url }.distinct() + .ifEmpty { GroupRelaySet.General.defaults().map { it.url.url } } + } + + /** One request per relay, each carrying the NIP's two queries for the coordinate. */ + internal fun requestsFor(schema: GroupCuratedSchema, relays: List): List = + relays.map { relayUrl -> + SynchronizeNostrEventRequest( + purpose = PURPOSE, + synchronizationFilters = schema.queueFilters(), + relayURL = relayUrl, + level = 0, + ) + } + + /** + * The state once [queue] has been read off the stored rows. Something + * arriving means the relays have answered, so the asking ends there; + * nothing arriving leaves it to the timer. + */ + internal fun withQueue( + loaded: CuratedSuggestionListUIState.Loaded, + queue: List, + ): CuratedSuggestionListUIState.Loaded = loaded.copy( + suggestions = queue, + isAsking = loaded.isAsking && queue.isEmpty(), + ) + + /** The purpose the sync rows are filed under, for anything that counts them. */ + const val PURPOSE = "curated-queue" + + /** + * How long an empty queue is "being asked" before it is called empty. + * Longer than the reference client's six seconds because the request + * queues behind the pump's four subscription slots before it goes out. + */ + internal val ASK_TIMEOUT = 10.seconds + + fun factory( + chatRoomId: String, + activeUserPublicKey: HexKey, + relayHint: String?, + identifier: String, + initialCuratedSuggestionListUIState: CuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository: NostrRepository, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint, + identifier = identifier, + initialCuratedSuggestionListUIState = initialCuratedSuggestionListUIState, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt new file mode 100644 index 00000000..5d41d950 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/CuratedSuggestionListUIState.kt @@ -0,0 +1,40 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema + +sealed interface CuratedSuggestionListUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + /** The list whose queue this is, as the group last signed it. */ + val schema: GroupCuratedSchema, + /** + * Where the queue was read from: the relays the schema names, or the + * group's own where it names none. On the screen because a queue read + * from the wrong relays is an empty queue with no way to tell it from + * a list nobody has written to. + */ + val relays: List = emptyList(), + /** Everything suggested to the list, newest first. */ + val suggestions: List = emptyList(), + /** + * Whether the relays are still being asked. + * + * A pull through the sync queue has no end-of-stored-events the screen + * can see, so this is bounded by time rather than by an answer: true + * from the request going out until the first suggestion lands or the + * wait runs out. While it is true and nothing has arrived, the screen + * is looking rather than empty. + */ + val isAsking: Boolean = false, + ): CuratedSuggestionListUIState { + val curatedCount: Int get() = suggestions.count { it.isCurated } + } + + data class Error( + val message: String + ): CuratedSuggestionListUIState + + data object Loading: CuratedSuggestionListUIState +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt new file mode 100644 index 00000000..3cd150ca --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/CuratedSuggestionTest.kt @@ -0,0 +1,191 @@ +package press.mantra.compose.nostr + +import press.mantra.compose.database.GENESIS_AT +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +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.CuratedVisibility +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The queue of a list the group curates, read off whatever this device has + * stored for the coordinate. + * + * The rows come from a filter on kind and `a` tag, which is broader than the + * queue in three ways this reading has to close: an event that does not satisfy + * the schema, a kind 31890 signed by somebody other than the group, and an + * older version of an entry its author has since replaced. And one thing it + * has to add: whether the group has already taken a suggestion up, which is a + * canonical entry sharing its `d`. + * + * Also what to call a stranger. A profile that has arrived is a name; the + * placeholder the indexer writes on first sight is not, and a queue full of + * "LOADING..." would be a queue of nobody. + */ +class CuratedSuggestionTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val films = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = true, + config = CuratedFieldConfig(tag = "r", marker = "watch") + ), + ), + ).normalized(), + ) + + private val coordinate = films.coordinate() + + @Test + fun `the queue is every suggestion that satisfies the schema, newest first`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "older", "The older one", 1_700_000_000)), + stored(suggestion(bob, "newer", "The newer one", 1_700_000_900)), + // No watch link, which the schema requires: not shown with the + // gap blanked out, not shown at all. + stored(suggestion(ada, "broken", "The broken one", 1_700_000_500, watchUrl = null)), + // Another list's reply with this one's identifier. Not this queue. + stored(suggestion(ada, "elsewhere", "Somebody else's", 1_700_000_600, root = "31889:${"e".repeat(64)}:films")), + // A text note that happens to carry the coordinate. + stored(suggestion(ada, "note", "A note", 1_700_000_700).copy(kind = 1)), + ) + ) + + assertEquals(listOf("The newer one", "The older one"), queue.map { it.entry.title }) + assertTrue(queue.none { it.isCurated }) + } + + @Test + fun `an entry its author has replaced shows once, as the newer version`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin (2014)", 1_700_000_900)), + // The same `d` under another key is another entry: coordinates + // carry the author. + stored(suggestion(bob, "rise", "Bob's rise", 1_700_000_100)), + ) + ) + + assertEquals( + listOf("The Rise and Rise of Bitcoin (2014)", "Bob's rise"), + queue.map { it.entry.title } + ) + } + + @Test + fun `a suggestion the group has taken up is marked, and the group's entry is not in the queue`() { + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "rise", "The Rise and Rise of Bitcoin", 1_700_000_000)), + stored(suggestion(bob, "magic", "Magic Money", 1_700_000_100)), + stored(canonical(chatRoomId, "rise", "The Rise and Rise of Bitcoin", 1_700_000_900)), + // A kind 31890 by somebody who is not the curator is nobody's + // list, and marks nothing. + stored(canonical(bob, "magic", "Magic Money", 1_700_000_950)), + ) + ) + + assertEquals(listOf("Magic Money", "The Rise and Rise of Bitcoin"), queue.map { it.entry.title }) + assertEquals( + mapOf("Magic Money" to false, "The Rise and Rise of Bitcoin" to true), + queue.associate { it.entry.title to it.isCurated } + ) + } + + @Test + fun `a suggester is named by their profile, and by their key until one arrives`() { + val named = Profile( + publicKey = ada, + displayName = "Ada", + nostrEventId = "e".repeat(64), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + val placeholder = Profile( + publicKey = bob, + displayName = "LOADING...", + nostrEventId = "e".repeat(64), + createdAt = GENESIS_AT, + ) + + val queue = CuratedSuggestion.queueOf( + films, + listOf( + stored(suggestion(ada, "one", "One", 1_700_000_000), profile = named), + stored(suggestion(bob, "two", "Two", 1_700_000_100), profile = placeholder), + stored(suggestion("c".repeat(64), "three", "Three", 1_700_000_200), profile = null), + ) + ) + + assertEquals( + listOf("cccccccc", "bbbbbbbb", "Ada"), + queue.map { it.suggesterName() } + ) + assertFalse(queue.any { it.suggesterName() == "LOADING..." }) + } + + private fun suggestion( + pubKey: String, + identifier: String, + title: String, + createdAt: Long, + watchUrl: String? = "https://example.com/watch", + root: String = coordinate, + ) = NostrEvent( + id = createdAt.toString().padStart(60, '0') + pubKey.take(4), + pubKey = pubKey, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = listOfNotNull( + arrayOf("d", identifier), + arrayOf("title", title), + watchUrl?.let { arrayOf("r", it, "watch") }, + arrayOf("a", root, "", "root"), + ).toTypedArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt), + ) + + private fun canonical(pubKey: String, identifier: String, title: String, createdAt: Long) = + suggestion(pubKey, identifier, title, createdAt).copy(kind = CuratedSchemaEvent.CANONICAL_KIND) + + private fun stored(event: NostrEvent, profile: Profile? = null) = + LocalNostrEvent(nostrEvent = event, profile = profile) +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt new file mode 100644 index 00000000..8a9ca840 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/curated/CuratedEntryEventTest.kt @@ -0,0 +1,382 @@ +package press.mantra.compose.nostr.curated + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Kinds 31888 and 31890 as the NIP writes them, read and checked. + * + * The fixtures are the NIP's own examples -- the bitcoin.mov schema, *The Rise + * and Rise of Bitcoin* as `eebb74ab…` suggested it, and the curator signing it + * off -- because the one property worth pinning is that what that site accepts + * is accepted here and what it refuses is refused here for the same reason. + * `CuratedEntryEvent` is a port of the reference module's verifier rule for + * rule, and this is where each rule gets a case. + * + * The other thing pinned is the shape of what comes back: an entry keyed by the + * schema's field names, with the tags the schema does not define ignored rather + * than kept. `director`, `i` and `lang` are on the NIP's example and on no + * field of the NIP's schema, and they are not in the reading. + */ +class CuratedEntryEventTest { + private val curator = "7c965d8c2acdfd635562da3bcb82596b595be28b008d8b54ec702ed4c67d9d25" + private val suggester = "eebb74ab6bfb8485722ab1d4acee856eee96f17a9e1721f9c7cc63376c40b860" + private val stranger = "1".repeat(64) + + private val coordinate = "31889:$curator:bitcoin.mov" + + /** The NIP's schema, as `CuratedSchemaEventTest` reads it. */ + private val schema: CuratedSchema = assertNotNull( + CuratedSchemaEvent.parse( + Event( + id = "a".repeat(64), + pubKey = curator, + createdAt = 1735689600, + kind = CuratedSchemaEvent.KIND, + tags = arrayOf( + arrayOf("d", "bitcoin.mov"), + arrayOf("title", "bitcoin.mov suggestion"), + arrayOf("name", "bitcoin.mov"), + arrayOf("description", "Fields for a bitcoin.mov entry."), + arrayOf("k", "31888"), + arrayOf("visibility", "public"), + arrayOf("relay", "wss://ephemeral.mantra.press"), + arrayOf("field", "identifier", "token", "required", "", "Identifier", "{\"tag\":\"d\",\"max\":80,\"derived\":true}"), + arrayOf("field", "title", "text", "required", "", "Title", "{\"max\":200}"), + arrayOf("field", "year", "year", "optional", "", "Year", "{\"min\":1900,\"max\":2100}"), + arrayOf("field", "type", "enum", "required", "", "Type", "{\"options\":[\"movie\",\"documentary\",\"short\",\"interview\",\"series\",\"other\"]}"), + arrayOf("field", "watchUrl", "url", "optional", "", "Watch / reference URL", "{\"tag\":\"r\",\"marker\":\"watch\",\"max\":500,\"repeat\":true}"), + arrayOf("field", "imdbUrl", "url", "optional", "", "IMDb URL", "{\"tag\":\"r\",\"marker\":\"imdb\",\"max\":500}"), + arrayOf("field", "image", "image", "optional", "", "Poster image URL (https)", "{\"max\":500}"), + arrayOf("field", "hashtags", "token", "optional", "", "Hashtags", "{\"tag\":\"t\",\"max\":60,\"repeat\":true,\"derived\":true}"), + arrayOf("field", "description", "longtext", "optional", "", "Description / review", "{\"tag\":\"content\",\"max\":4000}"), + arrayOf("require-any", "watchUrl", "imdbUrl"), + ), + content = "Fields for a bitcoin.mov entry.", + sig = "b".repeat(128), + ) + ) + ) + + private val review = "Follows programmer Daniel Mross and the early Bitcoin community from 2011 onward." + + /** *The Rise and Rise of Bitcoin*, as the NIP's example has it. */ + private val riseAndRise: Array> = arrayOf( + arrayOf("d", "imdb:tt2821314"), + arrayOf("title", "The Rise and Rise of Bitcoin"), + arrayOf("year", "2014"), + arrayOf("type", "documentary"), + arrayOf("director", "Nicholas Mross"), + arrayOf("r", "http://bitcoindoc.com/", "watch"), + arrayOf("r", "https://www.imdb.com/title/tt2821314/", "imdb"), + arrayOf("image", "https://upload.wikimedia.org/wikipedia/en/9/96/The_Rise_and_Rise_of_Bitcoin_%282014%29_Film_Poster.jpg"), + arrayOf("i", "imdb:tt2821314"), + arrayOf("lang", "en"), + arrayOf("t", "bitcoin"), + arrayOf("t", "documentary"), + arrayOf("a", coordinate, "wss://ephemeral.mantra.press", "root"), + arrayOf("p", curator), + arrayOf("k", "31889"), + ) + + private val suggestionId = "a809ddc2ad9c7b99e739e872b1b9e99e96ca9a3a4cb28cdf220c74324277eee8" + + @Test + fun `the NIP's suggestion satisfies the NIP's schema, and reads by field`() { + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(), schema, coordinate)) + + val entry = assertNotNull(CuratedEntryEvent.suggestion(suggestion(), schema, coordinate)) + + assertEquals(CuratedEntryEvent.SUGGESTION_KIND, entry.kind) + assertEquals(suggester, entry.pubKey) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals("The Rise and Rise of Bitcoin", entry.title) + assertEquals("31888:$suggester:imdb:tt2821314", entry.coordinate()) + assertNull(entry.source) + + // By the schema's field names, not by tag: the two `r` tags land on + // two fields, the `t` tags on one, and the content on the field that + // writes there. + assertEquals("2014", entry.value("year")) + assertEquals("documentary", entry.value("type")) + assertEquals("http://bitcoindoc.com/", entry.value("watchUrl")) + assertEquals("https://www.imdb.com/title/tt2821314/", entry.value("imdbUrl")) + assertEquals(listOf("bitcoin", "documentary"), entry.values["hashtags"]) + assertEquals(review, entry.value("description")) + + // What the schema does not define is not read, however much of it there is. + assertEquals( + setOf("identifier", "title", "year", "type", "watchUrl", "imdbUrl", "image", "hashtags", "description"), + entry.values.keys + ) + } + + @Test + fun `the minimum suggestion is enough`() { + // The doc's own minimum: nothing but what the schema insists on, and an + // IMDb link standing in for a watch link under the require-any rule. + val minimum = suggestion( + tags = arrayOf( + arrayOf("d", "bitcoin-the-end-of-money-as-we-know-it"), + arrayOf("title", "Bitcoin: The End of Money as We Know It"), + arrayOf("type", "documentary"), + arrayOf("r", "https://www.imdb.com/title/tt4654844/", "imdb"), + arrayOf("t", "bitcoin"), + arrayOf("a", coordinate, "", "root"), + ), + content = "" + ) + + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(minimum, schema, coordinate)) + assertNull(assertNotNull(CuratedEntryEvent.suggestion(minimum, schema, coordinate)).value("description")) + } + + @Test + fun `the NIP's canonical entry is the list's own, and points at where it came from`() { + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(canonical(), schema, coordinate, curator)) + + val entry = assertNotNull(CuratedEntryEvent.canonical(canonical(), schema, coordinate, curator)) + + assertTrue(entry.isCanonical) + assertEquals("imdb:tt2821314", entry.identifier) + assertEquals("31890:$curator:imdb:tt2821314", entry.coordinate()) + assertEquals( + CuratedSuggestionRef( + id = suggestionId, + coordinate = "31888:$suggester:imdb:tt2821314", + pubKey = suggester + ), + entry.source + ) + } + + @Test + fun `a canonical entry nobody suggested has no source`() { + val unprompted = canonical(tags = riseAndRise) + + assertEquals(emptyList(), CuratedEntryEvent.verifyCanonical(unprompted, schema, coordinate, curator)) + assertNull(assertNotNull(CuratedEntryEvent.canonical(unprompted, schema, coordinate, curator)).source) + } + + @Test + fun `curation is not delegated`() { + // The same event, signed by the suggester: a valid suggestion and not a + // canonical entry, whatever its kind says. + val notTheCurator = canonical(pubKey = suggester) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_CURATOR, CuratedEntryProblem.Reason.NotTheCurator)), + CuratedEntryEvent.verifyCanonical(notTheCurator, schema, coordinate, curator) + ) + assertNull(CuratedEntryEvent.canonical(notTheCurator, schema, coordinate, curator)) + } + + @Test + fun `a source that is not a suggestion coordinate is refused`() { + val malformed = canonical( + tags = riseAndRise + arrayOf(arrayOf("a", "31888:not-a-pubkey:imdb:tt2821314", "", "mention")) + ) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SOURCE, CuratedEntryProblem.Reason.SourceMalformed)), + CuratedEntryEvent.verifyCanonical(malformed, schema, coordinate, curator) + ) + } + + @Test + fun `the wrong kind is not an entry of either sort`() { + val note = suggestion(kind = 1) + + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_KIND, CuratedEntryProblem.Reason.WrongKind)), + CuratedEntryEvent.verifySuggestion(note, schema, coordinate) + ) + // And a suggestion is not a canonical entry: same tags, same author rule aside. + assertTrue( + CuratedEntryEvent.verifyCanonical(suggestion(), schema, coordinate, curator) + .any { it.reason == CuratedEntryProblem.Reason.WrongKind } + ) + } + + @Test + fun `a suggestion has to reply to this schema`() { + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.NotAReply)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("a")), schema, coordinate) + ) + + // Somebody else's list with the same identifier is somebody else's list. + val elsewhere = riseAndRise.replacing("a", "31889:$stranger:bitcoin.mov") + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_SCHEMA, CuratedEntryProblem.Reason.RepliesElsewhere)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = elsewhere), schema, coordinate) + ) + } + + @Test + fun `a required field has to be there`() { + assertEquals( + listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.Missing)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("title")), schema, coordinate) + ) + assertEquals( + listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.Missing)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.without("type")), schema, coordinate) + ) + } + + @Test + fun `a field that does not repeat appears once`() { + val twoPosters = riseAndRise + arrayOf(arrayOf("image", "https://example.com/other.jpg")) + assertEquals( + listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.Repeated)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = twoPosters), schema, coordinate) + ) + + // Where the schema says `repeat`, two is fine -- and both are read. + val twoWatchLinks = riseAndRise + arrayOf(arrayOf("r", "https://vimeo.com/1", "watch")) + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(tags = twoWatchLinks), schema, coordinate)) + assertEquals( + listOf("http://bitcoindoc.com/", "https://vimeo.com/1"), + CuratedEntryEvent.read(suggestion(tags = twoWatchLinks), schema).values["watchUrl"] + ) + } + + @Test + fun `every value has to match its field`() { + fun problemsWith(tag: String, value: String) = + CuratedEntryEvent.verifySuggestion(suggestion(tags = riseAndRise.replacing(tag, value)), schema, coordinate) + + // The things the doc lists as rejected, and why they matter. + assertEquals(listOf(CuratedEntryProblem("type", CuratedEntryProblem.Reason.NotAnOption)), problemsWith("type", "musical")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.BelowMinimum)), problemsWith("year", "1800")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.AboveMaximum)), problemsWith("year", "2200")) + assertEquals(listOf(CuratedEntryProblem("year", CuratedEntryProblem.Reason.NotAWholeNumber)), problemsWith("year", "twenty fourteen")) + assertEquals(listOf(CuratedEntryProblem("image", CuratedEntryProblem.Reason.NotHttps)), problemsWith("image", "http://example.com/poster.jpg")) + assertEquals(listOf(CuratedEntryProblem("title", CuratedEntryProblem.Reason.TooLong)), problemsWith("title", "x".repeat(201))) + assertEquals(listOf(CuratedEntryProblem("identifier", CuratedEntryProblem.Reason.NotOneWord)), problemsWith("d", "imdb tt2821314")) + + // A `javascript:` link ends up in an href. Replacing the watch link + // leaves the IMDb one, so require-any is still met and the URL is the + // only complaint. + val scripted = riseAndRise.map { + if (it[0] == "r" && it[2] == "watch") arrayOf("r", "javascript:alert(1)", "watch") else it + }.toTypedArray() + assertEquals( + listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.NotAUrl)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = scripted), schema, coordinate) + ) + } + + @Test + fun `a require-any group needs one of its fields`() { + val noLinks = riseAndRise.filter { it[0] != "r" }.toTypedArray() + + // Filed under the group's first field, so a form can show it inline. + assertEquals( + listOf(CuratedEntryProblem("watchUrl", CuratedEntryProblem.Reason.RequireAnyUnmet)), + CuratedEntryEvent.verifySuggestion(suggestion(tags = noLinks), schema, coordinate) + ) + } + + @Test + fun `a closed list takes suggestions from the curator and the suggesters it names`() { + val closed = schema.copy(visibility = CuratedVisibility.Closed, suggesters = listOf(suggester)) + + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = suggester), closed, coordinate)) + assertEquals(emptyList(), CuratedEntryEvent.verifySuggestion(suggestion(pubKey = curator), closed, coordinate)) + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), + CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), closed, coordinate) + ) + + // Private is the same rule; the difference is who is shown the list, not who may write to it. + val private = closed.copy(visibility = CuratedVisibility.Private) + assertEquals( + listOf(CuratedEntryProblem(CuratedEntryEvent.FIELD_VISIBILITY, CuratedEntryProblem.Reason.NotPermitted)), + CuratedEntryEvent.verifySuggestion(suggestion(pubKey = stranger), private, coordinate) + ) + } + + @Test + fun `a value is checked by its type and its config`() { + val duration = CuratedField("runtime", CuratedFieldType.Duration, isRequired = false, config = CuratedFieldConfig(max = 36000)) + assertEquals(CuratedEntryProblem.Reason.NotPositive, CuratedEntryEvent.checkValue(duration, "0")) + assertEquals(CuratedEntryProblem.Reason.AboveMaximum, CuratedEntryEvent.checkValue(duration, "36001")) + // A numeric `max` is a bound, not a length: six digits under the bound is fine. + assertNull(CuratedEntryEvent.checkValue(duration, " 5400 ")) + + val pattern = CuratedField("code", CuratedFieldType.Token, isRequired = false, config = CuratedFieldConfig(pattern = "[a-z]{2,8}")) + assertNull(CuratedEntryEvent.checkValue(pattern, "abc")) + // End to end, not anywhere within. + assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(pattern, "abc1")) + + // A pattern this platform cannot compile is a check it cannot make, and + // an entry it cannot check is not one it vouches for. + val broken = pattern.copy(config = CuratedFieldConfig(pattern = "[unclosed")) + assertEquals(CuratedEntryProblem.Reason.PatternMismatch, CuratedEntryEvent.checkValue(broken, "abc")) + + assertEquals(CuratedEntryProblem.Reason.Empty, CuratedEntryEvent.checkValue(pattern, " ")) + + val strict = CuratedField("site", CuratedFieldType.Url, isRequired = false, config = CuratedFieldConfig(https = true)) + assertEquals(CuratedEntryProblem.Reason.NotHttps, CuratedEntryEvent.checkValue(strict, "http://example.com")) + assertNull(CuratedEntryEvent.checkValue(strict, "https://example.com")) + } + + @Test + fun `a coordinate splits on the first two colons only`() { + val parsed = assertNotNull(CuratedCoordinate.parse("31888:${suggester.uppercase()}:imdb:tt2821314")) + + assertEquals(31888, parsed.kind) + assertEquals(suggester, parsed.pubKey, "the pubkey was not lowercased") + assertEquals("imdb:tt2821314", parsed.identifier, "the identifier's own colon split it") + assertEquals("31888:$suggester:imdb:tt2821314", parsed.toValue()) + + assertNull(CuratedCoordinate.parse("31888:$suggester:"), "an empty identifier was accepted") + assertNull(CuratedCoordinate.parse("31888:not-a-pubkey:x")) + assertNull(CuratedCoordinate.parse("naddr1qqxnzd3cxqmrzv3exgmr2wfeqgs"), "a bech32 address is not a coordinate") + } + + private fun suggestion( + tags: Array> = riseAndRise, + content: String = review, + kind: Int = CuratedEntryEvent.SUGGESTION_KIND, + pubKey: String = suggester, + ): Event = Event( + id = suggestionId, + pubKey = pubKey, + createdAt = 1735689660, + kind = kind, + tags = tags, + content = content, + sig = "b".repeat(128), + ) + + /** The curator signing off on the suggestion, as the NIP's example has it. */ + private fun canonical( + tags: Array> = riseAndRise + arrayOf( + arrayOf("a", "31888:$suggester:imdb:tt2821314", "", "mention"), + arrayOf("e", suggestionId, "", "mention"), + arrayOf("p", suggester), + ), + pubKey: String = curator, + ): Event = Event( + id = "c".repeat(64), + pubKey = pubKey, + createdAt = 1735689720, + kind = CuratedEntryEvent.CANONICAL_KIND, + tags = tags, + content = review, + sig = "b".repeat(128), + ) + + private fun Array>.without(tag: String): Array> = + filter { it[0] != tag }.toTypedArray() + + private fun Array>.replacing(tag: String, value: String): Array> = + map { if (it[0] == tag) arrayOf(tag, value) + it.drop(2) else it }.toTypedArray() +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt new file mode 100644 index 00000000..62deff65 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/CuratedSuggestionListScreenJvmTest.kt @@ -0,0 +1,306 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.curated.CuratedEntry +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.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * What the queue screen says, in each of the states it can be in. + * + * Three things are worth pinning. **The row is driven by a schema the screen + * has never seen**, so what it puts on a row is a decision -- the fields that + * say what an entry is, labelled, and not the links -- and that decision is + * checked against a schema with one of each. **"Curated" is per row**, and a + * screen that marked the wrong row would send a member to re-curate what the + * group had already taken up. And **empty is not the same as still looking**: + * the screen has both and must say which, since a queue called empty a second + * before its rows arrive teaches a member not to believe it. + */ +@OptIn(ExperimentalTestApi::class) +class CuratedSuggestionListScreenJvmTest { + + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + private val bob = "b".repeat(64) + + private val schema = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + domain = "films.example", + fields = listOf( + CuratedField.IDENTIFIER, + CuratedField.TITLE, + CuratedField("year", CuratedFieldType.Year, isRequired = false, label = "Year"), + CuratedField("type", CuratedFieldType.Enum, isRequired = true, label = "Type"), + CuratedField( + "watchUrl", + CuratedFieldType.Url, + isRequired = false, + label = "Watch URL", + config = CuratedFieldConfig(tag = "r", marker = "watch") + ), + CuratedField( + "review", + CuratedFieldType.LongText, + isRequired = false, + label = "Review", + config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG) + ), + ), + relays = listOf("wss://relay.one.example") + ) + ) + + @Test + fun `the queue is named after the list, counted, and read from where the schema says`() = render( + state( + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false) + ) + ) + ) { + // The domain, as the card on the group's screen names it. + onNodeWithText("films.example").assertIsDisplayed() + onNodeWithText("2 suggestions · 1 curated").assertIsDisplayed() + onNodeWithText("Read from relay.one.example").assertIsDisplayed() + + // Newest first. + val newest = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot().top + val oldest = onNodeWithText("Magic Money").getBoundsInRoot().top + assertTrue(newest < oldest, "the queue was drawn oldest first") + } + + @Test + fun `a row shows what an entry is, not where to find it, and who suggested it`() = render( + state( + suggestions = listOf( + suggestion( + "Magic Money", + 1_700_000_000, + isCurated = false, + suggester = Profile( + publicKey = ada, + displayName = "Ada", + nostrEventId = "e".repeat(64), + createdAt = Instant.fromEpochSeconds(1_700_000_000) + ) + ) + ) + ) + ) { + // Labelled, because the screen does not know this list's fields any + // better than a stranger does; the link left to the sheet. + onNodeWithText("Year: 2018 · Type: documentary").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertDoesNotExist() + // The body, where the list has one. + onNodeWithText("Follows the early community.").assertIsDisplayed() + onNodeWithText("Suggested by Ada on", substring = true).assertIsDisplayed() + + onNodeWithText("1 suggestion").assertIsDisplayed() + onNodeWithText("Curated").assertDoesNotExist() + } + + @Test + fun `only the row the group has taken up is marked`() = render( + state( + suggestions = listOf( + suggestion("The Rise and Rise of Bitcoin", 1_700_000_900, isCurated = true), + suggestion("Magic Money", 1_700_000_000, isCurated = false) + ) + ) + ) { + onAllNodesWithText("Curated").assertCountEquals(1) + + val mark = onNodeWithText("Curated").getBoundsInRoot() + val curated = onNodeWithText("The Rise and Rise of Bitcoin").getBoundsInRoot() + val waiting = onNodeWithText("Magic Money").getBoundsInRoot() + + assertTrue(mark.top < waiting.top, "the mark fell onto the row still waiting") + assertTrue(mark.bottom > curated.top, "the mark floated above the row it is about") + } + + @Test + fun `a queue still being asked for is looking, not empty`() = render( + state(suggestions = emptyList(), isAsking = true) + ) { + onNodeWithText("Asking the list's relays…").assertIsDisplayed() + onNodeWithText("Nobody has suggested anything to this list yet.").assertDoesNotExist() + onNodeWithText("Ask again").assertDoesNotExist() + } + + @Test + fun `an empty queue says so, and offers to ask again`() = render( + state(suggestions = emptyList(), isAsking = false) + ) { + onNodeWithText("Nobody has suggested anything to this list yet.").assertIsDisplayed() + onNodeWithText("Ask again").assertIsDisplayed() + onNodeWithText("Asking the list's relays…").assertDoesNotExist() + onNodeWithText("0 suggestions").assertIsDisplayed() + } + + @Test + fun `the sheet has every field the entry answered, the link included, and the event`() = + runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + CuratedSuggestionSheetContent( + suggestion = suggestion("Magic Money", 1_700_000_000, isCurated = true), + schema = schema.schema + ) + } + } + } + + onNodeWithText("Magic Money").assertIsDisplayed() + onNodeWithText("Taken up by the group into the list.").assertIsDisplayed() + + // As the schema labels them, in the schema's order. + onNodeWithText("Year").assertIsDisplayed() + onNodeWithText("2018").assertIsDisplayed() + onNodeWithText("Watch URL").assertIsDisplayed() + onNodeWithText("https://example.com/watch").assertIsDisplayed() + onNodeWithText("Review").assertIsDisplayed() + onNodeWithText("Follows the early community.").assertIsDisplayed() + + // The event, whole, and a way to take it somewhere. + onNodeWithText("\"kind\":31888", substring = true).assertIsDisplayed() + onNodeWithText("Copy the suggestion").assertIsDisplayed() + } + + private fun suggestion( + title: String, + createdAt: Long, + isCurated: Boolean, + suggester: Profile? = null + ): CuratedSuggestion { + val id = createdAt.toString().padStart(64, 'e') + val identifier = title.lowercase().replace(' ', '-') + + return CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = id, + pubKey = suggester?.publicKey ?: bob, + createdAt = createdAt, + identifier = identifier, + title = title, + values = mapOf( + "identifier" to listOf(identifier), + "title" to listOf(title), + "year" to listOf("2018"), + "type" to listOf("documentary"), + "watchUrl" to listOf("https://example.com/watch"), + "review" to listOf("Follows the early community."), + ) + ), + event = NostrEvent( + id = id, + pubKey = suggester?.publicKey ?: bob, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf(arrayOf("d", identifier), arrayOf("title", title)), + content = "Follows the early community.", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt) + ), + suggester = suggester, + isCurated = isCurated + ) + } + + private fun state( + suggestions: List, + isAsking: Boolean = false + ) = CuratedSuggestionListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ), + schema = schema, + relays = listOf("wss://relay.one.example"), + suggestions = suggestions, + isAsking = isAsking + ) + + /** + * The screen at a phone's width and a window tall enough to compose the + * whole of it. The state is passed in, so the NO_OP repositories are never + * asked for anything: `initiate` only runs for a screen that arrives loading. + */ + private fun render( + uiState: CuratedSuggestionListUIState, + assertions: ComposeUiTest.() -> Unit + ) = runDesktopComposeUiTest(width = 400, height = 2000) { + setContent { + MantraTheme { + ProvideSnackbarHost { + Box(modifier = Modifier.fillMaxSize()) { + CuratedSuggestionListScreen( + activeUserPublicKey = ada, + chatRoomId = chatRoomId, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = uiState, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + onNavigateBack = {} + ) + } + } + } + } + + assertions() + } +} 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 dead059f..b89d56eb 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 @@ -250,8 +250,37 @@ class GroupNostrProfileSectionJvmTest { onNodeWithText("Films worth translating").assertIsDisplayed() onNodeWithText("Add schema").assertDoesNotExist() onNodeWithContentDescription("Edit schema").assertDoesNotExist() + + // The queue is the one thing in the block the group did not write, + // and reading it takes no share of the key. + onNodeWithText("View suggestions").assertIsDisplayed() } + @Test + fun `every schema offers its own queue, under its own card`() = render( + state( + groupNostrProfile = profile, + schemas = listOf( + schema("films", "Films worth translating", 1_700_000_900), + schema("books", "Books worth translating", 1_700_000_000) + ) + ) + ) { + // One per list, because each list has its own queue -- and under the + // card it belongs to rather than once for the section, since a button + // between two cards would say nothing about which it opened. + val buttons = onAllNodesWithText("View suggestions") + buttons.assertCountEquals(2) + + val films = onNodeWithText("Films worth translating").getBoundsInRoot() + val books = onNodeWithText("Books worth translating").getBoundsInRoot() + val first = buttons[0].getBoundsInRoot() + val second = buttons[1].getBoundsInRoot() + + assertTrue(films.top < first.top && first.top < books.top, "the first queue button is not under its card") + assertTrue(books.top < second.top, "the second queue button is not under its card") + } + @Test fun `a member holding a share of the key may propose any event the block can show`() = render( state(groupNostrProfile = profile, relayLists = signedRelayLists()) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt new file mode 100644 index 00000000..73b3463f --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CuratedSuggestionListViewModelJvmTest.kt @@ -0,0 +1,335 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.GroupSignedEvent +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.SynchronizeNostrEventRequest +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalNostrEvent +import press.mantra.compose.database.model.types.SynchronizationFilter +import press.mantra.compose.nostr.CuratedSuggestion +import press.mantra.compose.nostr.GroupCuratedSchema +import press.mantra.compose.nostr.GroupRelay +import press.mantra.compose.nostr.GroupRelayList +import press.mantra.compose.nostr.GroupRelaySet +import press.mantra.compose.nostr.Relays +import press.mantra.compose.nostr.curated.CuratedEntry +import press.mantra.compose.nostr.curated.CuratedField +import press.mantra.compose.nostr.curated.CuratedSchema +import press.mantra.compose.nostr.curated.CuratedSchemaEvent +import press.mantra.compose.nostr.curated.CuratedVisibility +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.CuratedSuggestionListUIState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * The decisions the queue screen's view model makes that nothing else can check. + * + * **Where the queue is read from.** The schema's relays, then the group's, then + * the default -- and getting the order wrong is quiet, because a queue read + * from the wrong relay is an empty queue with no error attached. + * + * **What is asked for.** One request per relay carrying the NIP's two queries, + * and the local watch over the same rows, so that what comes back is what is + * shown. + * + * **When looking stops.** Rows arriving end it; nothing arriving leaves it to + * the timer. A screen that stopped looking on an empty first read of the local + * table would call every list empty before its relays had been asked. + */ +class CuratedSuggestionListViewModelJvmTest { + private val chatRoomId = "d4c3b2a1".repeat(8) + private val ada = "a".repeat(64) + + private val one = RelayUrlNormalizer.normalize("wss://relay.one.example") + private val two = RelayUrlNormalizer.normalize("wss://relay.two.example") + + private fun schema(relays: List) = GroupCuratedSchema( + signedEvent = GroupSignedEvent( + id = "c".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = CuratedSchemaEvent.KIND, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ), + schema = CuratedSchema( + identifier = "films", + title = "Film suggestion", + name = "Films worth translating", + description = "Films the group would subtitle.", + visibility = CuratedVisibility.Public, + fields = listOf(CuratedField.IDENTIFIER, CuratedField.TITLE), + relays = relays, + ).normalized(), + ) + + private val localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = ada, + subject = "Translation room", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = "state" + ) + ) + + private fun general(relays: List, agreed: Boolean = true) = GroupRelayList( + set = GroupRelaySet.General, + signedEvent = if (agreed) { + GroupSignedEvent( + id = "a".repeat(64), + chatRoomId = chatRoomId, + publicKey = chatRoomId, + kind = GroupRelaySet.General.kind, + tags = emptyArray(), + content = "", + signature = "f".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + } else { + null + }, + relays = relays, + ) + + @Test + fun `the schema's own relays come first, and two spellings of one are one`() { + val asked = CuratedSuggestionListViewModel.relaysToAsk( + schema(listOf("wss://relay.one.example", "wss://relay.two.example/", "wss://relay.one.example/")), + general = general(listOf(GroupRelay(RelayUrlNormalizer.normalize("wss://relay.three.example")))), + ) + + assertEquals(listOf(one.url, two.url), asked) + assertEquals(2, asked.size, "one relay spelled two ways was asked twice") + } + + @Test + fun `a schema naming no relays is read from where the group reads`() { + val three = RelayUrlNormalizer.normalize("wss://relay.three.example") + val four = RelayUrlNormalizer.normalize("wss://relay.four.example") + + // The group's read relays, and not its write-only one: this is a read. + assertEquals( + listOf(three.url), + CuratedSuggestionListViewModel.relaysToAsk( + schema(emptyList()), + general = general(listOf(GroupRelay(three), GroupRelay(four, read = false, write = true))), + ) + ) + + // Nothing agreed, or agreed empty: the build's own relay rather than nobody. + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = null) + ) + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk(schema(emptyList()), general = general(emptyList())) + ) + assertEquals( + listOf(Relays.ephemeral.url), + CuratedSuggestionListViewModel.relaysToAsk( + schema(emptyList()), + general = general(listOf(GroupRelay(three)), agreed = false) + ) + ) + } + + @Test + fun `one request per relay, carrying the NIP's two queries for the coordinate`() { + val schema = schema(listOf(one.url)) + val requests = CuratedSuggestionListViewModel.requestsFor(schema, listOf(one.url, two.url)) + + assertEquals(listOf(one.url, two.url), requests.map { it.relayURL }) + assertTrue(requests.all { it.purpose == CuratedSuggestionListViewModel.PURPOSE }) + + val (suggestions, canonical) = requests.first().synchronizationFilters + val coordinate = "31889:$chatRoomId:films" + + assertEquals(listOf(31888), suggestions.kinds?.toList()) + assertEquals(mapOf("a" to listOf(coordinate)), suggestions.tags) + assertEquals(null, suggestions.authors, "suggestions are not scoped by author: anyone may make one") + + assertEquals(listOf(31890), canonical.kinds?.toList()) + assertEquals(listOf(chatRoomId), canonical.authors?.toList(), "canonical entries are the group's alone") + assertEquals(mapOf("a" to listOf(coordinate)), canonical.tags) + + // And the watch covers both, so what is asked for is what is shown. + val watched = schema.queueReadFilter() + assertEquals(listOf(31888, 31890), watched.kinds?.toList()) + assertEquals(mapOf("a" to listOf(coordinate)), watched.tags) + } + + @Test + fun `rows arriving end the looking, and an empty read does not`() { + val asking = CuratedSuggestionListUIState.Loaded( + localChatRoom = localChatRoom, + schema = schema(listOf(one.url)), + relays = listOf(one.url), + isAsking = true, + ) + + val stillEmpty = CuratedSuggestionListViewModel.withQueue(asking, emptyList()) + assertTrue(stillEmpty.isAsking, "an empty first read of the local table was taken as the relays' answer") + + val arrived = CuratedSuggestionListViewModel.withQueue(asking, listOf(row("Magic Money", 1_700_000_000))) + assertFalse(arrived.isAsking) + assertEquals(listOf("Magic Money"), arrived.suggestions.map { it.entry.title }) + + // Once the timer has called it empty, a later arrival is still shown. + val late = CuratedSuggestionListViewModel.withQueue(stillEmpty.copy(isAsking = false), listOf(row("Late", 1_700_000_900))) + assertFalse(late.isAsking) + assertEquals(listOf("Late"), late.suggestions.map { it.entry.title }) + } + + @Test + fun `opening the screen asks the schema's relays and shows what the table holds for them`() { + val schema = schema(listOf(one.url)) + val queued = mutableListOf() + val watched = mutableListOf() + + val stored = NostrEvent( + id = "1".repeat(64), + pubKey = ada, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = arrayOf( + arrayOf("d", "magic-money"), + arrayOf("title", "Magic Money"), + arrayOf("a", schema.coordinate(), "", "root"), + ), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(1_700_000_000), + ) + + val nostrRepository = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun queueSynchronizeNostrEvent(synchronizeNostrEventRequests: List) { + queued += synchronizeNostrEventRequests + } + + override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow> { + watched += synchronizationFilter + return flowOf(listOf(LocalNostrEvent(nostrEvent = stored, profile = null))) + } + } + + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = listOf(schema) + override suspend fun groupRelayLists(chatRoomId: String): List = emptyList() + } + + val viewModel = CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + ) + + try { + viewModel.initiate() + + // The read runs on IO and lands on the state when it lands; the + // assertion waits for the rows rather than for a fixed time. + val loaded = runBlocking { + withTimeout(5_000) { + while (true) { + val state = viewModel.curatedSuggestionListUIState + if (state is CuratedSuggestionListUIState.Loaded && state.suggestions.isNotEmpty()) break + delay(20) + } + assertIs(viewModel.curatedSuggestionListUIState) + } + } + + assertEquals(listOf(one.url), loaded.relays) + assertEquals(listOf(one.url), queued.map { it.relayURL }) + assertEquals(listOf(schema.queueReadFilter()), watched) + assertEquals(listOf("Magic Money"), loaded.suggestions.map { it.entry.title }) + assertFalse(loaded.isAsking, "rows arrived and the screen is still looking") + } finally { + // The timer would otherwise run on for ten seconds after the test. + viewModel.viewModelScope.cancel() + } + } + + @Test + fun `a schema this device does not hold is an error rather than an empty queue`() { + val chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = localChatRoom + override suspend fun groupCuratedSchemas(chatRoomId: String): List = emptyList() + } + + val viewModel = CuratedSuggestionListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = ada, + relayHint = null, + identifier = "films", + initialCuratedSuggestionListUIState = CuratedSuggestionListUIState.Loading, + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = chatRepository, + ) + + try { + viewModel.initiate() + + runBlocking { + withTimeout(5_000) { + while (viewModel.curatedSuggestionListUIState == CuratedSuggestionListUIState.Loading) delay(20) + } + } + + assertEquals( + CuratedSuggestionListUIState.Error("Couldn't find that schema"), + viewModel.curatedSuggestionListUIState + ) + } finally { + viewModel.viewModelScope.cancel() + } + } + + private fun row(title: String, createdAt: Long) = CuratedSuggestion( + entry = CuratedEntry( + kind = CuratedSchemaEvent.SUGGESTION_KIND, + id = createdAt.toString().padStart(64, 'e'), + pubKey = ada, + createdAt = createdAt, + identifier = title.lowercase().replace(' ', '-'), + title = title, + values = mapOf("title" to listOf(title)), + ), + event = NostrEvent( + id = createdAt.toString().padStart(64, 'e'), + pubKey = ada, + kind = CuratedSchemaEvent.SUGGESTION_KIND, + tags = emptyArray(), + content = "", + sig = "b".repeat(128), + createdAt = Instant.fromEpochSeconds(createdAt), + ), + suggester = null, + isCurated = false, + ) +} diff --git a/docs/scripts/m3-title-case.py b/docs/scripts/m3-title-case.py index 02a4c11a..51f154c5 100755 --- a/docs/scripts/m3-title-case.py +++ b/docs/scripts/m3-title-case.py @@ -29,6 +29,8 @@ SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via', SAMPLE = { 'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing', 'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words', + # Films, as suggested to a curated list in the queue screen's preview. + 'The Rise and Rise of Bitcoin', 'Magic Money', }