feat(groups): the queue of a list the group curates, read off the relays it named

`930d37c8` gave a group the schema: the definition of a list, signed by the room's
own key, for strangers to answer. This is the answers. Under every schema card on the
group's screen there is now a "View suggestions" button, and it opens the list's
*queue* -- every kind 31888 anyone has published in reply to the schema's coordinate,
newest first, whoever signed it, with a mark on each one the group has already taken
up into the list. It is the second half of the curated-list NIP in this app, the
read side of it: suggestions (31888) and canonical entries (31890) are now read and
checked here. They are still not written; that is the third half.

The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that
site's one list: one row per suggestion event rather than one per film, so that a
reader can see what is waiting before anything is added and whether the curator has
taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the
reference for everything the screen decides -- one row per event, the newest
version per coordinate, "Curated" where a canonical entry shares the `d`, and a
timer standing in for an answer that never comes.

**The button is behind no gate, and it is per card.** Every other button in the
identity block is hidden from a member holding no share of the key, because each of
them proposes a signature by the group. The queue is the one thing in the block the
group did not write. A schema exists to be answered by strangers, and reading the
answers takes no share of anything, so the button is there for whoever is looking
-- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a
schema and finds it. It sits under its own card rather than once for the section,
in the same `item {}` as the card, because each list has its own queue and a button
between two cards would say nothing about which one it opened.

**The protocol is ported rule for rule, and the fixtures are the NIP's own.**
`nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`,
`verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind
is the one the schema names; 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 this schema and no other; and the
author is somebody the visibility admits. A canonical entry gets two rules on top --
its author is the schema's author, because curation is the one power a schema does
not delegate, and a source pointer, when there is one, is a well-formed suggestion
coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest`
takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's
example, and the curator's sign-off on it, and checks both against the bitcoin.mov
schema from the same document; then the doc's own minimum suggestion, which has an
IMDb link and no watch link and satisfies `require-any` that way; then every
rejection the doc lists and says why it matters -- an unknown type, a year outside
1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a
`d` with a space in it -- plus the reply rules, the repeat rule, the closed-list
rule and the two canonical rules.

**Rejected, not repaired, and read by field.** The NIP says a client MUST verify an
entry against its schema and MUST reject one that does not satisfy it rather than
showing it with the bad parts blanked out; relays carry malformed, partial and
hostile events from other applications. So `suggestion` and `canonical` return the
entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the
reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema.
problems` is an enum rather than a sentence -- so that a form later can show them
inline. What comes back is a `CuratedEntry` 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 and
the tag is only where it was kept: the two `r` tags of the example land on
`watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body
on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no
field of the NIP's schema and are not in the reading -- "tags the schema does not
define MAY be present and MUST be ignored".

**A pattern this platform cannot compile counts as not matched.** The reference
builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on
a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring
an uncompilable pattern would accept entries the site refuses; refusing them is the
conservative reading, and it is documented at the check.

**A coordinate splits on the first two colons only.** A `d` may itself contain
colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses --
so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`,
and the test pins the identifier surviving its own colon and the pubkey being
lowercased on the way through.

**The queue is a reading over stored rows, with three things to close and one to
add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the
coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the
queue out of it. Closed: an event that does not satisfy the schema; a kind 31890
signed by anybody but the group, which is somebody else's list and marks nothing;
and an older version of an entry its author has since replaced, since both kinds
are addressable and different relays hold different latest versions, so the merge
that realises an edit happens here, newest per `kind:pubkey:d` with the event id
breaking ties. Added: `isCurated`, true where a canonical entry the group signed
shares the suggestion's identifier -- the coordinate both land on, and the
reference page's rule. Newest first, because that is the order a queue is read in.

**A stranger is named by their profile, and by their key until one arrives.** The
indexer writes a placeholder profile named "LOADING..." the moment a pubkey is
first seen, and a queue full of that would be a queue of nobody.
`suggesterName` reads the profile only when it is a real one -- `createdAt` past
`GENESIS_AT` -- and otherwise the short key, which is at least stable and
distinguishing. The row's avatar is tinted from the key like every avatar here, so
two suggestions by one stranger read as one stranger.

**Read from where the schema says, and the screen says where.** The schema's
`relay` tags are signed into the event 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 naming none -- the NIP allows it and lets a client pick
-- is read from the group's general relay list, the *read* relays of it, since this
is a read; and failing an agreed, non-empty one, from this build's own relay, the
same fallback the schema editor seeds from. Spellings are normalised so one relay
written two ways is one request. The header names the relays asked, because a
queue read from the wrong relays and a list nobody has written to look exactly
alike from here, and a member should be able to tell them apart.

**Nothing here talks to a relay; it is a pull like every other one-off read.**
`CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay
for the sync pump to drain, carrying the NIP's two queries as one request's two
filters -- everything anyone published in reply to the coordinate, and what the
*group* published in reply to it, the second scoped by `authors` to spare the relay
the work `queueOf` does regardless -- and watches the local table for what comes
back. That watch needed a way to observe an arbitrary NIP-01 filter, which the
repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes
and falls back to text notes for the rest, and none of its shapes is "these kinds
with this `a` tag". `observeNostrEventsMatching` applies the filter whole through
`NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is
anchored and escaped rather than a substring scan; the DAO method behind it names
its observed tables explicitly, the events 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. 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. `NostrEvent.toEvent` is the small
conversion the reading needs to hand a stored row to a verifier written against
the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the
group's own.

**"Still looking" is bounded by time, because nothing else bounds it.** The pump
marks a request sent when the REQ goes out and processed when an event comes back.
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 a screen that showed the
local table's first, empty read would call every list empty for the second before
its rows arrived -- which teaches a member not to believe it. So the queue is
"being asked" from the request going out until either something lands or ten
seconds pass, and only after that is an empty queue empty. The reference store does
the same with a six-second timer; ten here because the request queues behind the
pump's four subscription slots before it goes out. Rows arriving end it early, a
late arrival after it is still shown, and `withQueue` -- the one state merge --
pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers
"Ask again", which re-queues the same requests: the one thing the watch cannot do
on its own is make more arrive.

**The row shows what an entry is, not where to find it.** The screen is driven by
a schema it has never seen, so what goes on a row is a decision rather than a
layout: the title as the headline, since every list has one; a glance line of the
form fields that say what the entry is, as `Label: value`, because a bare
"2014 · en" says nothing to somebody who does not know the list's fields; the body
where the list has one; and who suggested it, when. Links are left to the sheet --
a URL on a row is a line of noise. "Curated" sits on the row as 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.

**The sheet has the whole entry and the event it came as.** Every field the entry
answered, labelled as the schema labels it and in the schema's order, so it reads
as the form would have; the title not repeated, since it is the heading; tokens and
links monospaced because those are compared and copied rather than read. Then the
signed event, byte for byte, with a copy button -- the way the key state sheet
shows the group's own statement and for the same reason: a prettier rendering is a
different string to the one whose id was hashed. It is also the thing a canonical
entry is built from, when that half arrives.

**Four states, and the transition between them.** Loading while the room and the
schema are read; an error, with nothing to retry, for a schema this device does
not hold, since the identifier came from a navigation argument and reading it
again fails the same way; and a loaded state that is either the rows, or looking,
or empty with something to do. `ScreenStateTransition` wraps the `when`, which is
the Scaffold's whole content.

**The audit's proper-noun list grows by two films.** The preview suggests *The Rise
and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and
`m3-title-case.py` excludes sample data by name rather than by pattern.

**Still nothing has reached a relay, and it bites once more.** Group-signed events
are not yet published, so a group's schema has not been where a suggester could
find it, and its queue is empty until it has. The screen reads the coordinate
`31889:<group>:<d>` all the same and shows whatever a relay holds for it; the
suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are
correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at
the reading.

32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion
and canonical entry against the NIP's schema: the reading by field, the minimum
suggestion, the source pointer and its absence, curation not being delegated, a
malformed source, the wrong kind, the reply rules, a missing required field, the
repeat rule both ways, every value rule the doc lists, `require-any`, the closed
and private lists, `checkValue` on durations, patterns and https, and the
coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows:
verified suggestions newest first with the broken, the foreign and the wrong-kind
ones dropped, an edit collapsing to its newest version, the curated mark placed by
the group's canonical entry and not a stranger's, and the naming of a suggester
with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest`
(6) covers the relay order and the read-only filter, one request per relay carrying
both queries and the watch covering both kinds, the three outcomes of `withQueue`,
an `initiate` run end to end against fakes -- the relays asked, the filter watched,
the rows shown, the looking ended -- and a missing schema being an error rather
than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header,
the row's glance line with the link kept off it, the mark on the right row and only
that row, looking against empty, and the sheet with its fields, its link and its
event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the
button under each card in turn and in front of a member with no share.

1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@1e52fc8f25
This commit is contained in:
Kgothatso Ngako
2026-09-12 12:54:34 +02:00
parent 14ffa04e7c
commit b127647145
20 changed files with 2988 additions and 16 deletions

View File

@@ -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 @@
<string name="the_ceremony_was_abandoned">The ceremony was abandoned.</string>
<string name="the_group_can_hold_one_key_together_split_so">The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum.</string>
<string name="the_group_has_a_shared_key">The group has a shared key.</string>
<string name="the_recovery_phrase_sometimes_called_a_seed">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.</string>
<string name="the_recovery_phrase_sometimes_called_a_seed">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.</string>
<string name="these_are_your_keys_keep_them_safe_so_they">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.</string>
<string name="this_artifact_has_no_version_for_a">This artifact has no version for a translation to be of.</string>
<string name="this_artifact_has_no_version_for_a_chapter">This artifact has no version for a chapter to attach to.</string>
@@ -570,6 +570,21 @@
<string name="content_must_be_text">Content must be text.</string>
<string name="the_content_is_not_a_profile">The content isn't a profile. A kind 0 carries a JSON object of profile fields.</string>
<string name="this_schema_cannot_be_signed_as_it_is">This schema can't be signed as it is:</string>
<string name="view_suggestions">View suggestions</string>
<string name="suggestions">Suggestions</string>
<string name="everything_anyone_has_suggested_to_this_list">Everything anyone has suggested to this list, newest first. Only the group can take one up into the list.</string>
<string name="one_suggestion">1 suggestion</string>
<string name="n_suggestions">%1$s suggestions</string>
<string name="n_curated">%1$s curated</string>
<string name="read_from_relays">Read from %1$s</string>
<string name="asking_the_list_s_relays">Asking the list's relays…</string>
<string name="nobody_has_suggested_anything_to_this_list_yet">Nobody has suggested anything to this list yet.</string>
<string name="ask_again">Ask again</string>
<string name="curated">Curated</string>
<string name="suggested_by_on">Suggested by %1$s on %2$s</string>
<string name="taken_up_by_the_group_into_the_list">Taken up by the group into the list.</string>
<string name="copy_the_suggestion">Copy the suggestion</string>
<string name="copied_the_suggestion">Copied the suggestion</string>
<!-- Sign in: docs/nsec-sign-in.md, phase 4. -->
<string name="a_password_protected_key_ncryptsec_is_not">A password-protected key (ncryptsec) is not supported yet. Decrypt it in the app it came from and paste the nsec.</string>
<string name="an_npub_is_a_public_key_mantra_needs_the">An npub is a public key. Mantra needs the secret key, the nsec, to sign as you.</string>

View File

@@ -77,6 +77,17 @@ interface NostrEventDao {
query: RoomRawQuery
): List<NostrEvent>
/**
* 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<List<press.mantra.compose.database.model.intermdiate.LocalNostrEvent>>
@Transaction
@Query("SELECT * FROM NostrEvent WHERE kind in (:kinds) AND createdAt > :since ORDER BY createdAt ASC LIMIT :limit")
suspend fun getNostrEvents(

View File

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

View File

@@ -738,6 +738,12 @@ class DatabaseNostrRepository(
}
}
override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow<List<press.mantra.compose.database.model.intermdiate.LocalNostrEvent>> {
return database.nostrEventDao().observeNostrEventsMatchingFilter(
NostrEventFilterQuery.build(synchronizationFilter)
)
}
override suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array<SynchronizationFilter>,
applyLimits: Boolean

View File

@@ -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<LocalNostrEvent>): List<CuratedSuggestion> {
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<CuratedSuggestion> { it.entry.createdAt }
.thenByDescending { it.entry.id }
)
}
}
}

View File

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

View File

@@ -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<CuratedEntryProblem> =
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<CuratedEntryProblem> = 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<Array<String>>, content: String): List<String> {
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<Array<String>>): 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<CuratedEntryProblem> = 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<Array<String>>, name: String): String? =
tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1)
private fun firstAddress(tags: Array<Array<String>>, 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<String, List<String>>,
/** 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:<suggester>:<d>`, 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, `<kind>:<pubkey>:<d>`, 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,
)
}
}
}

View File

@@ -175,6 +175,16 @@ interface NostrRepository {
suspend fun observeNostrFeed(synchronizationFilter: SynchronizationFilter): Flow<List<LocalNostrEvent>>
/**
* 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<List<LocalNostrEvent>>
suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array<SynchronizationFilter>,
applyLimits: Boolean = true
@@ -385,6 +395,10 @@ interface NostrRepository {
TODO("Not yet implemented")
}
override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow<List<LocalNostrEvent>> {
TODO("Not yet implemented")
}
override suspend fun getNegentropicNostrFeedIds(
synchronizationFilters: Array<SynchronizationFilter>,
applyLimits: Boolean

View File

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

View File

@@ -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<String?>(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<String>) {
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 = {}
)
}
}
}

View File

@@ -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<CuratedSuggestionListRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CuratedSuggestionListRoute>()
CuratedSuggestionListScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
identifier = route.identifier,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ProposeGroupEventRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ProposeGroupEventRoute>()

View File

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

View File

@@ -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<String>) {
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<String> {
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<String>): List<SynchronizeNostrEventRequest> =
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<CuratedSuggestion>,
): 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,
)
}
}
}
}

View File

@@ -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<String> = emptyList(),
/** Everything suggested to the list, newest first. */
val suggestions: List<CuratedSuggestion> = 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
}

View File

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

View File

@@ -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<Array<String>> = 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<Array<String>> = 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<Array<String>> = 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<Array<String>>.without(tag: String): Array<Array<String>> =
filter { it[0] != tag }.toTypedArray()
private fun Array<Array<String>>.replacing(tag: String, value: String): Array<Array<String>> =
map { if (it[0] == tag) arrayOf(tag, value) + it.drop(2) else it }.toTypedArray()
}

View File

@@ -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<CuratedSuggestion>,
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()
}
}

View File

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

View File

@@ -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<String>) = 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<GroupRelay>, 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<SynchronizeNostrEventRequest>()
val watched = mutableListOf<SynchronizationFilter>()
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<SynchronizeNostrEventRequest>) {
queued += synchronizeNostrEventRequests
}
override suspend fun observeNostrEventsMatching(synchronizationFilter: SynchronizationFilter): Flow<List<LocalNostrEvent>> {
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<GroupCuratedSchema> = listOf(schema)
override suspend fun groupRelayLists(chatRoomId: String): List<GroupRelayList> = 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<CuratedSuggestionListUIState.Loaded>(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<GroupCuratedSchema> = 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,
)
}

View File

@@ -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',
}