feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign

The queue's sheet used to end in the suggestion's JSON and a button to copy
it. Now it ends in the one thing a member can do about a suggestion: take it
into the list. That opens a form seeded from the suggestion -- an input per
field the schema asks for, enums as chips, one input per value where a field
repeats -- and a button that proposes a kind 31890 for the room's quorum to
sign, pointing back at the suggestion it came from.

The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate
follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the
same field list as the reader, so what the form proposes is what the queue
reads back. The one deviation is that nothing is clipped on the way through; a
value the schema refuses is named under its input instead, by the queue's own
verifier, before a quorum is spent on it. The d is kept whatever field writes
it, so accepting twice revises one entry rather than making two, and derived
fields are kept as the suggester's client filled them in, since this app
derives nothing.

After the quorum signs, a 31890 has somewhere to land: the signing screen says
which entry and who suggested it, the transcript gets a line naming the list,
and the broadcast screen seeds the schema's relays, which the NIP makes a MUST
and which the entry only names by coordinate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@fcc19f9500
This commit is contained in:
Kgothatso Ngako
2026-09-12 15:23:17 +02:00
parent 39b1c714b8
commit 8d32a69bf3
21 changed files with 2571 additions and 85 deletions

View File

@@ -583,8 +583,34 @@
<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>
<string name="accept_this_suggestion">Accept this suggestion</string>
<string name="revise_the_list_s_entry">Revise the list's entry</string>
<!-- Taking one suggestion into a list the group curates: the form, and the entry it proposes. -->
<string name="take_this_suggestion_into_the_list">Take this suggestion into %1$s, edited as the group wants it. The entry keeps the suggestion's identifier and points back at it, so the suggester is credited.</string>
<string name="the_group_signs_an_entry_so_it_takes_a_quorum">The group signs an entry, so it takes a quorum. Nothing changes until one has.</string>
<string name="this_group_has_no_shared_key_to_sign_an_entry">This group has no shared key, so it cannot sign an entry. Run a shared key ceremony first.</string>
<string name="the_group_already_has_an_entry_for_this">The group already has an entry for this. Proposing again revises it in place rather than adding a second one.</string>
<string name="propose_entry">Propose entry</string>
<string name="could_not_ask_the_group_to_sign_this_entry">Couldn't ask the group to sign this entry. Please try again.</string>
<string name="this_entry_cannot_be_signed_as_it_is">This entry can't be signed as it is:</string>
<string name="the_entry_cannot_be_signed_as_it_is">The entry can't be signed as it is.</string>
<string name="kept_from_the_suggestion">Kept from the suggestion</string>
<string name="filled_in_by_the_suggester_s_app_and_kept">Filled in by the suggester's app and kept as they came, the identifier among them, so that an entry accepted twice lands on the same coordinate.</string>
<string name="add_another">Add another</string>
<string name="this_field_is_required">This field is required.</string>
<string name="only_one_value_for_this_field">Only one value for this field.</string>
<string name="one_word_without_spaces">One word, without spaces.</string>
<string name="that_is_not_a_web_address">That is not a web address.</string>
<string name="that_must_be_an_https_address">That must be an https address.</string>
<string name="pick_one_of_the_options">Pick one of the options.</string>
<string name="a_whole_number">A whole number.</string>
<string name="more_than_zero">More than zero.</string>
<string name="at_least_n">At least %1$s.</string>
<string name="at_most_n">At most %1$s.</string>
<string name="at_most_n_characters">At most %1$s characters.</string>
<string name="must_match_the_pattern">Must match %1$s.</string>
<string name="at_least_one_of_these_is_needed">At least one of these is needed: %1$s.</string>
<string name="field_and_problem">%1$s: %2$s</string>
<!-- Broadcasting one event the group signed to relays a member picks. -->
<string name="broadcast">Broadcast</string>
<string name="sends_the_event_exactly_as_the_group_signed_it">Sends the event to each relay below, exactly as the group signed it. Any member holding it can send it: the signature is the group's, not yours.</string>

View File

@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupNostrProfile
import press.mantra.compose.nostr.curated.CuratedEntryEvent
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.GroupRelaySet
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
@@ -447,11 +448,22 @@ data class ChatMessage(
*/
const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned"
/**
* The group signed an entry into a list it curates.
*
* A system line like the schema's, and it names the entry and the list
* rather than the fields: what a reader of the room needs to know is
* that the list now stands behind a title, and the entry itself is on
* the queue screen where its fields can be read.
*/
const val TYPE_GROUP_ENTRY_CURATED = "groupEntryCurated"
val GROUP_IDENTITY_TYPES = setOf(
TYPE_GROUP_PROFILE_SIGNED,
TYPE_GROUP_RELAYS_SIGNED,
TYPE_GROUP_POST_SIGNED,
TYPE_GROUP_SCHEMA_SIGNED,
TYPE_GROUP_ENTRY_CURATED,
)
/**
@@ -1579,6 +1591,49 @@ data class ChatMessage(
)
}
// The group taking an entry into a list it curates: the kind
// 31890 that only its own key can publish, and the thing the
// queue screen marks a suggestion "curated" by once it is read
// back off a relay.
//
// Verified, for the reason the schema arm is: a rumor of this
// kind from a member is not the group curating anything. Named
// after the list where this device holds its schema -- looked
// up by the coordinate the entry replies to -- and after the
// entry alone where it does not, since the entry is what was
// signed either way.
CuratedEntryEvent.CANONICAL_KIND -> {
val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId)
if (!signed.verifies()) return null
val entry = CuratedEntryEvent.headline(event)
val title = entry.title.ifBlank { entry.identifier }.ifBlank { "an entry" }
val list = runCatching {
GroupCuratedSchema.newestPerListAmong(
signedEvents = database.groupSignedEventDao()
.getByChatRoomIdAndKind(groupId, CuratedSchemaEvent.KIND),
chatRoomId = groupId,
).firstOrNull { it.isRepliedToBy(event.tags) }
}.getOrNull()
ChatMessage(
giftWrapPayloadId = null,
messageType = TYPE_GROUP_ENTRY_CURATED,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = if (list != null) {
"The group accepted $title into ${list.schema.displayName()}"
} else {
"The group accepted $title into a list it curates"
}
)
}
else -> unsupported()
}
}

View File

@@ -34,7 +34,7 @@ import kotlin.time.Instant
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. */
/** The event as it was stored, for the id and the tags: what a canonical entry points back at. */
val event: NostrEvent,
/** The suggester's profile where one has arrived, else null. */
val suggester: Profile?,

View File

@@ -2,8 +2,11 @@ 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.CuratedEntryEvent
import press.mantra.compose.nostr.curated.CuratedEntryProblem
import press.mantra.compose.nostr.curated.CuratedSchema
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.curated.CuratedSuggestionRef
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.time.Instant
@@ -57,6 +60,14 @@ data class GroupCuratedSchema(
/** What a suggestion's `a` root names to reply to this list. */
fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier)
/**
* Whether an entry carrying [tags] replies to this list: its `a` tags
* include this coordinate. How a reader holding several of the group's
* schemas tells which one an entry belongs to.
*/
fun isRepliedToBy(tags: Array<Array<String>>): Boolean =
tags.any { it.size > 1 && it[0] == COORDINATE_TAG && it[1] == coordinate() }
/**
* What to ask a relay for to read this list's queue: the NIP's two queries,
* as one request.
@@ -93,6 +104,43 @@ data class GroupCuratedSchema(
tags = mapOf(COORDINATE_TAG to listOf(coordinate())),
)
/**
* The event to ask the group to sign to take an entry into the list:
* [values] as this schema lays them out, rooted at [coordinate], with the
* group as the curator and [source] as the suggestion it came from.
*
* The group is the curator, so its key is the `p` on the root and the only
* author [verifyCanonical] will accept. See [CuratedEntryEvent.canonicalTemplate]
* for what is and is not done to the values on the way through.
*/
fun canonicalTemplate(
values: Map<String, List<String>>,
source: CuratedSuggestionRef?,
): EventTemplate<*> = CuratedEntryEvent.canonicalTemplate(
schema = schema,
coordinate = coordinate(),
curator = publicKey,
values = values,
source = source,
)
/**
* Every reason this list's readers would refuse [template] as one of its
* own entries, or nothing.
*
* The same check `CuratedSuggestion.queueOf` makes on a kind 31890 off a
* relay, made before the group is asked to sign one: a signature is the
* most expensive thing this app does, and an entry the queue would then
* refuse to count is a quorum spent on nothing.
*/
fun verifyCanonical(template: EventTemplate<*>): List<CuratedEntryProblem> =
CuratedEntryEvent.verifyCanonical(
event = CuratedEntryEvent.preview(template, publicKey),
schema = schema,
coordinate = coordinate(),
curator = publicKey,
)
companion object {
/** The tag a reply carries its schema's coordinate in. */
private const val COORDINATE_TAG = "a"

View File

@@ -3,6 +3,8 @@ 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
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.time.Clock
/**
* Kinds 31888 and 31890: an entry in a curated list, as somebody suggested it and
@@ -18,7 +20,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
* 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.
* and an entry it refuses is refused here for the same reason. The write side,
* [canonicalTemplate], is the port of its `buildCuratedCanonicalTemplate`, and
* walks the same field list as [read] so the two cannot drift.
*
* ### Rejected, not repaired
*
@@ -40,6 +44,12 @@ object CuratedEntryEvent {
private const val TAG_EVENT = "e"
private const val TAG_IDENTIFIER = "d"
private const val TAG_TITLE = "title"
private const val TAG_PUBKEY = "p"
private const val TAG_KIND = "k"
/** NIP-10's markers: the schema being replied to, and the suggestion being credited. */
private const val MARKER_ROOT = "root"
private const val MARKER_MENTION = "mention"
/** What every schema coordinate starts with, which is how a root is told from a source. */
private val SCHEMA_PREFIX = "${CuratedSchemaEvent.KIND}:"
@@ -87,6 +97,20 @@ object CuratedEntryEvent {
)
}
/**
* The parts of an entry that need no schema to read: its identifier, its
* title and, for a canonical entry, where it came from.
*
* For a reader that has the event and not the list -- the signing screen,
* the transcript -- and wants to say which entry this is rather than check
* it. [CuratedEntry.values] is empty, since without a schema there are no
* fields to key them by.
*/
fun headline(event: Event): CuratedEntry = read(event, NO_SCHEMA)
/** A schema with no fields, for [headline]: the two lifted tags are read regardless. */
private val NO_SCHEMA = CuratedSchema(identifier = "", title = "", name = "", description = "", visibility = null)
/**
* Every reason [event] is not a suggestion to the list at [coordinate], or
* nothing.
@@ -128,6 +152,128 @@ object CuratedEntryEvent {
}
}
/**
* The unsigned canonical entry for the curator to sign: [values] laid out as
* the schema's tags, rooted at the schema's [coordinate], and pointing back
* at [source] when it came from a suggestion.
*
* The reverse of [read], and the reason a canonical entry and the suggestion
* it came from are one shape: both walk the schema's one field list, so what
* this writes is what [canonical] reads back, field for field. Ported from
* the reference module's `buildCuratedCanonicalTemplate` tag for tag, so
* that an entry this app signs is one bitcoin.mov's list shows.
*
* [values] is keyed by field name the way [CuratedEntry.values] is, derived
* fields included. The reference client derives `d` and `t` for a suggestion
* it is composing; this app composes none, and a suggestion it accepts keeps
* what the suggester's client derived -- the `d` above all, which the NIP
* says a canonical entry SHOULD keep so that re-curating revises the entry
* in place rather than adding a second one. Nothing is clipped or repaired
* on the way through: a value the schema refuses is refused by
* [verifyCanonical] on the result, with the field named, which is what a
* form wants to show. A blank value is not a value and writes nothing, and a
* field without `repeat` writes its first value only.
*
* [relayHint] goes on the root, per NIP-10: where a reader who does not know
* the list can find the schema being replied to. The NIP says it SHOULD be
* the first of the schema's relays, and by default it is.
*/
fun canonicalTemplate(
schema: CuratedSchema,
coordinate: String,
curator: HexKey,
values: Map<String, List<String>>,
source: CuratedSuggestionRef? = null,
relayHint: String? = schema.relays.firstOrNull(),
createdAt: Long = Clock.System.now().epochSeconds,
): EventTemplate<Nothing> {
val (fieldTags, content) = fieldTags(schema, values)
return EventTemplate(
createdAt = createdAt,
kind = CANONICAL_KIND,
tags = (fieldTags + replyTags(coordinate, curator, relayHint) + sourceTags(source)).toTypedArray(),
content = content,
)
}
/**
* [template] as an event the verifiers can read: the kind, the tags, the
* content and [pubKey], which is everything they look at, over a blank id
* and signature, which they do not. For checking what is about to be
* proposed before a quorum is spent on it.
*/
fun preview(template: EventTemplate<*>, pubKey: HexKey): Event = Event(
id = "",
pubKey = pubKey,
createdAt = template.createdAt,
kind = template.kind,
tags = template.tags,
content = template.content,
sig = "",
)
/**
* The tags that make an entry a reply to its schema, and nothing else.
*
* The root is the schema's coordinate rather than an event id, because the
* kind is addressable: revising the schema mints a new id and keeps the
* coordinate, and an entry pinned to an id would be orphaned by the first
* edit. `p` and `k` are the reply courtesies the NIP asks for and nothing
* verifies; `a` is what is verified and what relays are queried on.
*/
fun replyTags(coordinate: String, curator: HexKey, relayHint: String?): List<Array<String>> = listOf(
arrayOf(TAG_ADDRESS, coordinate, relayHint ?: "", MARKER_ROOT),
arrayOf(TAG_PUBKEY, curator),
arrayOf(TAG_KIND, CuratedSchemaEvent.KIND.toString()),
)
/**
* The values as the schema's tags: one per value at the field's tag, with
* its marker as the third element when it has one, or the body for the
* field that writes there.
*/
private fun fieldTags(schema: CuratedSchema, values: Map<String, List<String>>): Pair<List<Array<String>>, String> {
val tags = mutableListOf<Array<String>>()
var content = ""
for (field in schema.fields) {
val tag = field.tag()
val marker = field.config.marker
for (value in values[field.name].orEmpty()) {
val trimmed = value.trim()
if (trimmed.isEmpty()) continue
if (tag == CuratedFieldConfig.CONTENT_TAG) {
content = trimmed
} else {
tags.add(if (marker != null) arrayOf(tag, trimmed, marker) else arrayOf(tag, trimmed))
}
if (field.config.repeat != true) break
}
}
return tags to content
}
/**
* Both halves of the pointer back: the coordinate, which follows the
* suggester's later edits, and the id, which pins what the curator actually
* read. They answer different questions, which is why the NIP has both. The
* `p` credits the suggester.
*/
private fun sourceTags(source: CuratedSuggestionRef?): List<Array<String>> {
if (source == null) return emptyList()
return buildList {
add(arrayOf(TAG_ADDRESS, source.coordinate, "", MARKER_MENTION))
source.id?.let { add(arrayOf(TAG_EVENT, it, "", MARKER_MENTION)) }
add(arrayOf(TAG_PUBKEY, source.pubKey))
}
}
/**
* One value against its field's type and config, or the first thing wrong
* with it.
@@ -359,7 +505,25 @@ data class CuratedSuggestionRef(
val coordinate: String,
/** The suggester, credited. */
val pubKey: HexKey,
)
) {
companion object {
/**
* The pointer a canonical entry made from [entry] carries: its id, its
* coordinate and its author. Only a suggestion can be pointed at -- a
* canonical entry is the list's own word, and the list did not suggest
* anything to itself.
*/
fun of(entry: CuratedEntry): CuratedSuggestionRef {
require(!entry.isCanonical) { "A canonical entry is not the source of another" }
return CuratedSuggestionRef(
id = entry.id,
coordinate = entry.coordinate(),
pubKey = entry.pubKey,
)
}
}
}
/**
* One reason an entry does not satisfy its schema: which field, and what about

View File

@@ -3,8 +3,10 @@ package press.mantra.compose.text
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import press.mantra.compose.extensions.shortened
import press.mantra.compose.managers.SharedKeyDerivation
import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.nostr.curated.CuratedEntryEvent
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
@@ -208,6 +210,21 @@ object ProposedEvent {
}
)
// An entry the group would take into one of its lists. A member signing
// this is agreeing that the list stands behind this title, so the
// summary is the title and who suggested it -- the credit the entry
// carries -- and not its fields, which the form showed whole before the
// proposal was made and the queue shows again once it lands.
CuratedEntryEvent.CANONICAL_KIND -> Summary(
label = "Curated entry",
detail = CuratedEntryEvent.headline(event).let { entry ->
listOfNotNull(
entry.title.ifBlank { entry.identifier }.ifBlank { null },
entry.source?.let { "suggested by ${it.pubKey.shortened()}" }
).joinToString(" · ").ifEmpty { "An untitled entry" }
}
)
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
}
}

View File

@@ -0,0 +1,843 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
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.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.a_whole_number
import mantra.composeapp.generated.resources.add_another
import mantra.composeapp.generated.resources.at_least_n
import mantra.composeapp.generated.resources.at_least_one_of_these_is_needed
import mantra.composeapp.generated.resources.at_most_n
import mantra.composeapp.generated.resources.at_most_n_characters
import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_entry
import mantra.composeapp.generated.resources.field_and_problem
import mantra.composeapp.generated.resources.filled_in_by_the_suggester_s_app_and_kept
import mantra.composeapp.generated.resources.kept_from_the_suggestion
import mantra.composeapp.generated.resources.more_than_zero
import mantra.composeapp.generated.resources.must_match_the_pattern
import mantra.composeapp.generated.resources.one_word_without_spaces
import mantra.composeapp.generated.resources.only_one_value_for_this_field
import mantra.composeapp.generated.resources.pick_one_of_the_options
import mantra.composeapp.generated.resources.propose_entry
import mantra.composeapp.generated.resources.suggested_by_on
import mantra.composeapp.generated.resources.take_this_suggestion_into_the_list
import mantra.composeapp.generated.resources.that_is_not_a_web_address
import mantra.composeapp.generated.resources.that_must_be_an_https_address
import mantra.composeapp.generated.resources.the_entry_cannot_be_signed_as_it_is
import mantra.composeapp.generated.resources.the_group_already_has_an_entry_for_this
import mantra.composeapp.generated.resources.the_group_signs_an_entry_so_it_takes_a_quorum
import mantra.composeapp.generated.resources.this_entry_cannot_be_signed_as_it_is
import mantra.composeapp.generated.resources.this_field_is_required
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_entry
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.CuratedEntryProblem
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.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative
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.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.AcceptCuratedSuggestionViewModel
import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
import kotlin.time.Instant
/**
* The form for taking one suggestion into a list the group curates.
*
* The queue screen shows what was suggested; this is where the group decides
* what the list will say. Every field the schema's form asks for is an input
* seeded from the suggestion, because curation is editorial -- the NIP lets
* the curator fix a year or swap a poster on the way through, and a button
* that could only say yes would leave a typo in the list forever. What the
* form does not ask for it shows: the identifier, which the entry keeps so
* that accepting it twice revises one entry rather than making two, and the
* fields the suggester's app filled in, which this app has no way to fill in
* itself.
*
* **The button proposes rather than saves.** The entry goes out as one kind
* 31890 for the room's quorum to put its key to, pointing back at the
* suggestion it came from, and the screen hands over to the signing session it
* opened. Nothing about the list has changed by the time this screen closes.
* The form is driven by a schema this screen has never seen, so a value the
* schema refuses is named under its own input rather than found out after a
* quorum has signed.
*/
@OptIn(
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class
)
@Composable
fun AcceptCuratedSuggestionScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
identifier: String,
suggestionId: String,
initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState =
AcceptCuratedSuggestionUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateBack: () -> Unit,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
) {
val acceptCuratedSuggestionViewModel: AcceptCuratedSuggestionViewModel = viewModel(
factory = AcceptCuratedSuggestionViewModel.factory(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
identifier = identifier,
suggestionId = suggestionId,
initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
LaunchedEffect(true) {
if (initialAcceptCuratedSuggestionUIState == AcceptCuratedSuggestionUIState.Loading) {
acceptCuratedSuggestionViewModel.initiate()
}
}
// Read out here rather than in a click handler: both are composable and an
// onClick lambda is not. The scope is the caller's so a message survives this
// screen being replaced by the signing session.
val notify = rememberNotifier(rememberCoroutineScope())
val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_entry)
ScreenStateTransition(acceptCuratedSuggestionViewModel.acceptCuratedSuggestionUIState) { uiState ->
when (val state = uiState) {
is AcceptCuratedSuggestionUIState.Error -> {
// Nothing to retry: the room, the schema and the suggestion came
// from navigation arguments, and reading them again with the
// same ones fails the same way.
ErrorState(message = state.message, onRetry = null)
}
AcceptCuratedSuggestionUIState.Loading -> {
LoadingDataIndicator()
}
is AcceptCuratedSuggestionUIState.Loaded -> {
val canSign = state.canSign
// The inputs come from the state rather than from whatever loaded
// it, so a screen handed a loaded state -- a preview, a layout
// test -- fills in the same as the app does. Seeding runs once.
LaunchedEffect(state) {
acceptCuratedSuggestionViewModel.seedWorkingCopy(state)
}
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding(),
topBar = {
TopAppBar(
title = { state.localChatRoom.RenderChatRoomTitleText() },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
bottomBar = {
BottomAppBar(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!canSign) return@ExtendedFloatingActionButton
acceptCuratedSuggestionViewModel.proposeEntry(
state = state,
onSuccess = { sessionId ->
// Onto the session rather than back
// to the queue. The entry is not in
// the list yet -- it goes in when a
// quorum signs -- so landing on a
// queue still showing the row as
// waiting would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
sessionId = sessionId
)
)
},
// The reasons are drawn on the form
// itself, under the inputs they are
// about; a snackbar would name a
// problem and scroll away from it.
onInvalid = {},
// Stays on the form with the edits still
// in it: a failure to open a session is
// no reason to throw away the typing.
onFailure = { notify(couldNotPropose) }
)
}
) {
Icon(Icons.Default.Draw, contentDescription = "Propose entry")
Text(stringResource(Res.string.propose_entry))
}
}
)
}
) { innerPadding ->
AcceptCuratedSuggestionForm(
state = state,
viewModel = acceptCuratedSuggestionViewModel,
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
)
}
}
}
}
}
/**
* The form under the bars: what is being taken up and into what, the reasons
* it cannot be as it stands, an input per field the schema asks for, and the
* fields it keeps.
*
* A lazy list rather than a scrolling column, the way the schema editor is,
* because the number of inputs is the schema's to decide and a long form is
* still one screen. Split out so a layout test can render it without the bars.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun AcceptCuratedSuggestionForm(
state: AcceptCuratedSuggestionUIState.Loaded,
viewModel: AcceptCuratedSuggestionViewModel,
modifier: Modifier = Modifier
) {
val schema = state.schema.schema
val suggestion = state.suggestion
val editable = AcceptCuratedSuggestionViewModel.editableFields(state)
val kept = AcceptCuratedSuggestionViewModel.keptFields(state)
val entryProblems = viewModel.problemsAboutTheEntry(state)
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(
horizontal = MaterialTheme.spacing.screenMargin,
vertical = MaterialTheme.spacing.itemGap
),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text(
text = suggestion.entry.title,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
item {
Text(
text = stringResource(
Res.string.suggested_by_on,
suggestion.suggesterName(),
suggestion.suggestedAt.toFormattedTimeAndDateString()
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
item {
Text(
text = stringResource(Res.string.take_this_suggestion_into_the_list, schema.displayName()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
item {
Text(
text = stringResource(Res.string.the_group_signs_an_entry_so_it_takes_a_quorum),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
// Said up front rather than left to the queue's mark: a member who did
// not notice would propose what they took for a new entry and get a
// revision, which is the right outcome but a surprising one.
if (suggestion.isCurated) {
item {
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.the_group_already_has_an_entry_for_this),
style = MaterialTheme.typography.bodySmall
)
}
}
}
if (!state.canSign) {
item {
Text(
text = stringResource(Res.string.this_group_has_no_shared_key_to_sign_an_entry),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
}
// The problems no input can carry: about the entry as a whole, or about
// a field the form keeps rather than asks for. Named, and all at once,
// for the reason the schema editor gives -- a button that does nothing
// is indistinguishable from a missed tap.
if (entryProblems.isNotEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
) {
Text(
text = stringResource(Res.string.this_entry_cannot_be_signed_as_it_is),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
entryProblems.forEach { problem ->
val field = schema.fields.firstOrNull { it.name == problem.field }
Text(
text = stringResource(
Res.string.field_and_problem,
field?.labelOrName() ?: problem.field,
problem.message(field, schema)
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
editable.forEach { field ->
item(key = field.name) {
val inputs = viewModel.inputs[field.name].orEmpty()
val problem = viewModel.problemsFor(field.name).firstOrNull()
EntryFieldEditor(
field = field,
schema = schema,
inputs = inputs,
problem = problem,
onAddValue = { viewModel.addValue(field.name) },
onRemoveValue = { index -> viewModel.removeValue(field.name, index) }
)
}
}
if (kept.isNotEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
) {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.itemGap))
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(Res.string.kept_from_the_suggestion),
style = MaterialTheme.typography.titleSmall
)
Text(
text = stringResource(Res.string.filled_in_by_the_suggester_s_app_and_kept),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
kept.forEach { field ->
item(key = "kept:${field.name}") {
KeptField(field = field, values = suggestion.entry.values[field.name].orEmpty())
}
}
}
}
}
/**
* The inputs for one field, in the shape its type asks for.
*
* An enum is its options as chips, one selected, because typing a word that
* has to be one of six is a way to misspell it; everything else is a text
* field with the keyboard its type wants. A field that repeats is one input
* per value with a way to drop each and a way to add one, since the
* suggestion may have carried two watch links and the list may want a third.
* The schema's hint sits under the input until a problem takes its place.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EntryFieldEditor(
field: CuratedField,
schema: CuratedSchema,
inputs: List<TextFieldState>,
problem: CuratedEntryProblem?,
onAddValue: () -> Unit,
onRemoveValue: (index: Int) -> Unit
) {
val message = problem?.message(field, schema)
val options = field.config.options
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
) {
if (field.type == CuratedFieldType.Enum && !options.isNullOrEmpty()) {
EnumInput(field = field, options = options, input = inputs.firstOrNull(), message = message)
} else {
inputs.forEachIndexed { index, input ->
Row(verticalAlignment = Alignment.CenterVertically) {
EntryTextField(
modifier = Modifier.weight(1f),
state = input,
field = field,
// A problem is about the field, and the field is every
// input; the first is where a reader's eye lands.
message = message.takeIf { index == 0 },
isError = problem != null
)
if (field.config.repeat == true) {
IconButton(onClick = { onRemoveValue(index) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove this value",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
if (field.config.repeat == true) {
TextButton(onClick = onAddValue) {
Icon(Icons.Default.Add, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.relatedGap))
Text(stringResource(Res.string.add_another))
}
}
}
}
}
/**
* An enum as chips. The selection is written into the field's one input, so
* the draft reads an enum the way it reads everything else; a value the
* suggestion carried that is not among the options is shown as an unselected
* set with the problem under it, rather than quietly dropped.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EnumInput(
field: CuratedField,
options: List<String>,
input: TextFieldState?,
message: String?
) {
val selected = input?.text?.toString()
Text(
text = field.labelOrName(),
style = MaterialTheme.typography.labelMedium,
color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
) {
options.forEach { option ->
FilterChip(
selected = option == selected,
onClick = { input?.setTextAndPlaceCursorAtEnd(option) },
label = { Text(option) }
)
}
}
(message ?: field.config.hint)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = if (message != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* One text input of the form, in the shape the app's other forms use: the
* borderless outlined field over the bottom bar's tint, labelled as the
* schema labels it and placeholdered as the schema says.
*
* The keyboard follows the type -- digits for a year, a URL keyboard for a
* link -- and a long text gets room to be one. The supporting line is the
* problem when there is one and the schema's hint when there is not.
*/
@Composable
private fun EntryTextField(
state: TextFieldState,
field: CuratedField,
message: String?,
isError: Boolean,
modifier: Modifier = Modifier
) {
val keyboardType = when (field.type) {
CuratedFieldType.Year, CuratedFieldType.Duration, CuratedFieldType.Number -> KeyboardType.Number
CuratedFieldType.Url, CuratedFieldType.Image -> KeyboardType.Uri
else -> KeyboardType.Text
}
OutlinedTextField(
modifier = modifier.fillMaxWidth().background(BottomAppBarDefaults.containerColor),
state = state,
lineLimits = if (field.type == CuratedFieldType.LongText) {
TextFieldLineLimits.MultiLine(minHeightInLines = 3)
} else {
TextFieldLineLimits.SingleLine
},
isError = isError,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent
),
label = {
Text(text = field.labelOrName())
},
placeholder = field.placeholder.takeIf { it.isNotBlank() }?.let { { Text(text = it) } },
supportingText = (message ?: field.config.hint)?.let { { Text(text = it) } }
)
}
/**
* A field the form keeps rather than asks for: the schema's label over the
* values as the suggestion carried them, the way the queue's sheet shows an
* entry. Monospaced where the value is a thing rather than prose.
*/
@Composable
private fun KeptField(field: CuratedField, values: List<String>) {
val isMonospaced = when (field.type) {
CuratedFieldType.Token, CuratedFieldType.Url, CuratedFieldType.Image -> true
else -> false
}
Column(modifier = Modifier.fillMaxWidth()) {
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,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* One problem in words, for the input it is about.
*
* The bounds are read off the field's config, since "at most 500" says
* something and "too long" does not; a `require-any` rule names the other
* fields it could have been satisfied by, as the schema labels them.
*/
@Composable
internal fun CuratedEntryProblem.message(field: CuratedField?, schema: CuratedSchema): String = when (reason) {
CuratedEntryProblem.Reason.Missing,
CuratedEntryProblem.Reason.Empty -> stringResource(Res.string.this_field_is_required)
CuratedEntryProblem.Reason.Repeated -> stringResource(Res.string.only_one_value_for_this_field)
CuratedEntryProblem.Reason.NotOneWord -> stringResource(Res.string.one_word_without_spaces)
CuratedEntryProblem.Reason.NotAUrl -> stringResource(Res.string.that_is_not_a_web_address)
CuratedEntryProblem.Reason.NotHttps -> stringResource(Res.string.that_must_be_an_https_address)
CuratedEntryProblem.Reason.NotAnOption -> stringResource(Res.string.pick_one_of_the_options)
CuratedEntryProblem.Reason.NotAWholeNumber -> stringResource(Res.string.a_whole_number)
CuratedEntryProblem.Reason.NotPositive -> stringResource(Res.string.more_than_zero)
CuratedEntryProblem.Reason.BelowMinimum ->
stringResource(Res.string.at_least_n, field?.config?.min?.toString() ?: "")
CuratedEntryProblem.Reason.AboveMaximum ->
stringResource(Res.string.at_most_n, field?.config?.max?.toString() ?: "")
CuratedEntryProblem.Reason.TooLong ->
stringResource(Res.string.at_most_n_characters, field?.config?.max?.toString() ?: "")
CuratedEntryProblem.Reason.PatternMismatch ->
stringResource(Res.string.must_match_the_pattern, field?.config?.pattern ?: "")
CuratedEntryProblem.Reason.RequireAnyUnmet -> stringResource(
Res.string.at_least_one_of_these_is_needed,
schema.requireAny.firstOrNull { it.firstOrNull() == this.field }
.orEmpty()
.joinToString { name -> schema.fields.firstOrNull { it.name == name }?.labelOrName() ?: name }
)
// None of these can come out of the form: the builder writes the kind, the
// root and the source, and the group is the curator. Named all the same,
// since a check that cannot fail is the one that does.
CuratedEntryProblem.Reason.WrongKind,
CuratedEntryProblem.Reason.NotAReply,
CuratedEntryProblem.Reason.RepliesElsewhere,
CuratedEntryProblem.Reason.NotPermitted,
CuratedEntryProblem.Reason.NotTheCurator,
CuratedEntryProblem.Reason.SourceMalformed -> stringResource(Res.string.the_entry_cannot_be_signed_as_it_is)
}
@ConformancePreviews
@Composable
private fun AcceptCuratedSuggestionScreenPreview() {
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",
config = CuratedFieldConfig(options = listOf("movie", "documentary", "short"))
),
CuratedField(
"watchUrl",
CuratedFieldType.Url,
isRequired = false,
label = "Watch URL",
config = CuratedFieldConfig(tag = "r", marker = "watch", repeat = true)
),
CuratedField(
"description",
CuratedFieldType.LongText,
isRequired = false,
label = "Why",
config = CuratedFieldConfig(tag = CuratedFieldConfig.CONTENT_TAG)
),
CuratedField(
"hashtags",
CuratedFieldType.Token,
isRequired = false,
label = "Hashtags",
config = CuratedFieldConfig(tag = "t", repeat = true, derived = true)
),
),
relays = listOf("wss://relay.one.example")
)
)
val suggestion = CuratedSuggestion(
entry = CuratedEntry(
kind = CuratedSchemaEvent.SUGGESTION_KIND,
id = "a".repeat(64),
pubKey = suggester,
createdAt = 1_700_000_900,
identifier = "the-rise-and-rise-of-bitcoin-2014",
title = "The Rise and Rise of Bitcoin",
values = mapOf(
"identifier" to listOf("the-rise-and-rise-of-bitcoin-2014"),
"title" to listOf("The Rise and Rise of Bitcoin"),
"year" to listOf("2014"),
"type" to listOf("documentary"),
"watchUrl" to listOf("http://bitcoindoc.com/"),
"description" to listOf("Follows the early Bitcoin community from 2011 onward."),
"hashtags" to listOf("bitcoin", "documentary"),
)
),
event = NostrEvent(
id = "a".repeat(64),
pubKey = suggester,
kind = CuratedSchemaEvent.SUGGESTION_KIND,
tags = emptyArray(),
content = "",
sig = "b".repeat(128),
createdAt = Instant.fromEpochSeconds(1_700_000_900)
),
suggester = null,
isCurated = false
)
MantraTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AcceptCuratedSuggestionScreen(
activeUserPublicKey = "u".repeat(64),
chatRoomId = chatRoomId,
relayHint = null,
identifier = "films",
suggestionId = suggestion.id,
initialAcceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = chatRoomId,
userPublicKey = "u".repeat(64),
subject = "Translation room",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = "state"
)
),
schema = schema,
suggestion = suggestion,
canSign = true
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateBack = {},
onNavigateToRouteAndPopUpInclusive = {}
)
}
}
}

View File

@@ -185,6 +185,7 @@ fun BroadcastGroupSignedEventScreen(
kind = signedEvent.kind,
tags = signedEvent.tags,
content = signedEvent.content,
schemas = broadcastGroupSignedEventUIState.schemas,
)
}

View File

@@ -14,11 +14,11 @@ 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.material.icons.filled.Draw
import androidx.compose.material3.Button
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
@@ -33,13 +33,10 @@ 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
@@ -47,10 +44,9 @@ 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.accept_this_suggestion
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
@@ -58,10 +54,10 @@ 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.revise_the_list_s_entry
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
@@ -79,6 +75,8 @@ 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.navigation.routes.AcceptCuratedSuggestionRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.EmptyState
import press.mantra.compose.ui.composable.widgets.ErrorState
@@ -87,7 +85,6 @@ 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
@@ -109,9 +106,9 @@ import kotlin.time.Instant
* 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.
* A row opens a sheet with the whole entry, field by field, and the one thing
* a member can do about it from here: take it into the list, which opens the
* form that proposes the canonical entry for the group to sign.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -125,6 +122,7 @@ fun CuratedSuggestionListScreen(
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onNavigateBack: () -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
val curatedSuggestionListViewModel: CuratedSuggestionListViewModel = viewModel(
factory = CuratedSuggestionListViewModel.factory(
@@ -247,6 +245,18 @@ fun CuratedSuggestionListScreen(
CuratedSuggestionSheet(
suggestion = suggestion,
schema = state.schema.schema,
onAccept = {
openSuggestionId = null
onNavigateToRoute(
AcceptCuratedSuggestionRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
identifier = identifier,
suggestionId = suggestion.id
)
)
},
onDismiss = { openSuggestionId = null }
)
}
@@ -427,23 +437,24 @@ private fun bodyOf(entry: CuratedEntry, schema: CuratedSchema): String? =
private const val TITLE_TAG = "title"
/**
* The whole of one suggestion, and a way to take it somewhere else.
* The whole of one suggestion, and the way to take it into the list.
*
* 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.
* the sheet reads as the form would have -- and then the button, which is the
* one thing a member can do about a suggestion: open the form that proposes it
* as the list's own, for the group to sign. The event itself is not shown; it
* is what the form is built from, and the form shows it as fields rather than
* as JSON, which is how a member reads it and how the group will sign it.
*/
@Composable
private fun CuratedSuggestionSheet(
suggestion: CuratedSuggestion,
schema: CuratedSchema,
onAccept: () -> Unit,
onDismiss: () -> Unit
) {
ModalBottomSheet(onDismiss = onDismiss) {
CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema)
CuratedSuggestionSheetContent(suggestion = suggestion, schema = schema, onAccept = onAccept)
}
}
@@ -455,15 +466,9 @@ private fun CuratedSuggestionSheet(
@Composable
internal fun CuratedSuggestionSheetContent(
suggestion: CuratedSuggestion,
schema: CuratedSchema
schema: CuratedSchema,
onAccept: () -> Unit
) {
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()) {
@@ -514,44 +519,27 @@ internal fun CuratedSuggestionSheetContent(
}
}
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)
},
// The same button either way, worded for what it will do: a suggestion
// the group already took up is revised in place, since the canonical
// entry keeps its identifier, and a member should not have to find that
// out from the queue afterwards.
Button(
onClick = onAccept,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.ContentCopy, contentDescription = Decorative)
Icon(Icons.Default.Draw, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100))
Text(text = stringResource(Res.string.copy_the_suggestion))
Text(
text = stringResource(
if (suggestion.isCurated) {
Res.string.revise_the_list_s_entry
} else {
Res.string.accept_this_suggestion
}
)
)
}
}
}
@@ -683,7 +671,8 @@ private fun CuratedSuggestionListScreenPreview() {
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateBack = {}
onNavigateBack = {},
onNavigateToRoute = {}
)
}
}

View File

@@ -42,6 +42,7 @@ import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
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.AcceptCuratedSuggestionScreen
import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
import press.mantra.compose.ui.composable.BroadcastGroupSignedEventScreen
import press.mantra.compose.ui.composable.CuratedSuggestionListScreen
@@ -133,6 +134,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
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.AcceptCuratedSuggestionRoute
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute
import press.mantra.compose.ui.composable.navigation.routes.CuratedSuggestionListRoute
@@ -1128,6 +1130,35 @@ fun MantraNavHost(
chatRepository = databaseChatRepository,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRoute = { acceptRoute ->
navController.navigate(route = acceptRoute)
}
)
}
composable<AcceptCuratedSuggestionRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AcceptCuratedSuggestionRoute>()
AcceptCuratedSuggestionScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
identifier = route.identifier,
suggestionId = route.suggestionId,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateBack = {
navController.popBackStack()
},
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace the form so back returns to the queue rather
// than to an entry whose proposal has already gone out.
navController.navigate(route = signingRoute) {
popUpTo<AcceptCuratedSuggestionRoute> {
inclusive = true
}
}
}
)
}

View File

@@ -0,0 +1,22 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* The form for taking one suggestion into a list the group curates: the entry
* as it was suggested, open for the group's edits, and a button that asks the
* group to sign it as the list's own.
*
* [identifier] is the schema's `d`, for the reason `CuratedSuggestionListRoute`
* gives; [suggestionId] is the suggestion's event id rather than its
* coordinate, because the canonical entry pins the exact version the group
* read -- the NIP's `e` tag -- and the id is what names a version.
*/
@Serializable
data class AcceptCuratedSuggestionRoute(
val activeUserPublicKey: String,
val chatRoomId: String, // TODO: have this as a publicKey
val relayHint: String?,
val identifier: String,
val suggestionId: String
): Route()

View File

@@ -0,0 +1,324 @@
package press.mantra.compose.ui.view.model
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.snapshots.SnapshotStateMap
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import press.mantra.compose.nostr.CuratedSuggestion
import press.mantra.compose.nostr.curated.CuratedEntryProblem
import press.mantra.compose.nostr.curated.CuratedField
import press.mantra.compose.nostr.curated.CuratedSuggestionRef
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.AcceptCuratedSuggestionUIState
/**
* One suggestion, taken into the list: the entry as somebody suggested it,
* edited by a member, and proposed as the group's own.
*
* Nothing here is saved. The working copy is one input per value the entry
* has, held on this view model rather than in the composition so that the
* window turning does not throw an edit away, and the button folds it into a
* kind 31890 for the room's quorum to put its key to. The list changes on
* every member's device at once, when a quorum has signed, or not at all.
*
* ### What is edited, and what is kept
*
* Curation is editorial -- the NIP says the curator may fix a year or swap a
* poster on the way through -- so every field the schema's form asks for is
* open. Two things are not. The `d` is kept whatever field writes it, because
* the canonical entry SHOULD land on the suggestion's coordinate so that
* accepting it twice revises one entry rather than making two. And a field the
* schema marks `derived` is kept as the suggester's client filled it in: this
* app derives nothing, and an entry a form never asked for is not one a form
* should let a member invent.
*
* ### Refused before proposed
*
* The button runs the queue's own verifier over the event it is about to
* propose, and refuses with the fields named. A signature is the most expensive
* thing this app does, and a canonical entry the queue would not count -- a
* year outside the schema's range, an option that is not one -- would be a
* quorum spent on an entry no client shows.
*/
class AcceptCuratedSuggestionViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
/** The `d` of the schema whose list the entry goes into. */
val identifier: String,
/** The event id of the suggestion being taken up. */
val suggestionId: String,
initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var acceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState by mutableStateOf(
initialAcceptCuratedSuggestionUIState
)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* The working copy: for each field the form asks for, one input per value.
*
* A field that repeats has as many inputs as the suggestion had values, and
* can be given more; every other field has exactly one. Keyed by the
* schema's field name, which is what the entry is keyed by too.
*/
val inputs: SnapshotStateMap<String, SnapshotStateList<TextFieldState>> = mutableStateMapOf()
/**
* Why the last proposal did not go out, or nothing. Set by the button and
* left showing until the next press, since the button is the thing that
* will clear it.
*/
var problems: List<CuratedEntryProblem> by mutableStateOf(emptyList())
private set
private var isSeeded = false
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find the group")
return@launch
}
val schema = chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == identifier }
if (schema == null) {
acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that schema")
return@launch
}
// The same reading the queue screen makes, so what is accepted here
// is what was shown there: newest per coordinate, checked against
// the schema, and marked when the group already has an entry for it.
// An id the reading no longer holds is a version something newer has
// replaced since the row was tapped, and the newer one is what the
// queue now shows.
val events = nostrRepository.observeNostrEventsMatching(schema.queueReadFilter()).first()
val suggestion = CuratedSuggestion.queueOf(schema, events).firstOrNull { it.id == suggestionId }
if (suggestion == null) {
acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Error("Couldn't find that suggestion")
return@launch
}
acceptCuratedSuggestionUIState = AcceptCuratedSuggestionUIState.Loaded(
localChatRoom = localChatRoom,
schema = schema,
suggestion = suggestion,
canSign = localChatRoom.chatRoom.mlsGroupState != null &&
frostSigningRepository.canSign(chatRoomId),
)
}
}
/**
* Fills the working copy from the suggestion, once.
*
* One input per value the suggestion carries for each field the form asks
* for, and one blank input for a field it carries nothing for, so there is
* somewhere to type. Called from the screen rather than from [initiate],
* because the loader is not the only way a loaded state arrives: a preview
* and a layout test both hand one straight in. Runs once, so the effect
* that calls it re-firing does not undo an edit.
*/
fun seedWorkingCopy(state: AcceptCuratedSuggestionUIState.Loaded) {
if (isSeeded) return
isSeeded = true
val entry = state.suggestion.entry
editableFields(state).forEach { field ->
val values = entry.values[field.name].orEmpty().ifEmpty { listOf("") }
inputs[field.name] = mutableStateListOf(*values.map { TextFieldState(it) }.toTypedArray())
}
}
/** Adds a blank input under [fieldName], for a field that repeats. */
fun addValue(fieldName: String) {
inputs[fieldName]?.add(TextFieldState())
}
/**
* Drops the input at [index] under [fieldName]. The last input stays, blank
* rather than gone, so the field is still there to be typed into.
*/
fun removeValue(fieldName: String, index: Int) {
val values = inputs[fieldName] ?: return
if (index !in values.indices) return
if (values.size == 1) {
values[0] = TextFieldState()
} else {
values.removeAt(index)
}
}
/**
* The problems about [fieldName], for the field's own error state. A
* `require-any` rule is filed under its first field, so it shows there.
*/
fun problemsFor(fieldName: String): List<CuratedEntryProblem> = problems.filter { it.field == fieldName }
/**
* The problems that are about no field on the form: the entry as a whole,
* or a field the form keeps rather than asks for. Shown at the top, since
* there is no input to show them under.
*/
fun problemsAboutTheEntry(state: AcceptCuratedSuggestionUIState.Loaded): List<CuratedEntryProblem> {
val editable = editableFields(state).map { it.name }.toSet()
return problems.filterNot { it.field in editable }
}
/**
* The entry as the form has it: what was typed for every field the form
* asks for, and what was suggested for every field it keeps.
*
* Keyed by field name and holding every value, which is the shape
* `CuratedEntryEvent.canonicalTemplate` lays out as tags. Blank inputs come
* through and are dropped there; a blank is not a value.
*/
fun draft(state: AcceptCuratedSuggestionUIState.Loaded): Map<String, List<String>> {
val suggested = state.suggestion.entry.values
return state.schema.schema.fields.associate { field ->
field.name to (inputs[field.name]?.map { it.text.toString() } ?: suggested[field.name].orEmpty())
}
}
/**
* Opens a session over the entry, or says why not.
*
* Two refusals before anything is proposed and each is the caller's to
* say: an entry the queue would refuse, which is [onInvalid] with the
* reasons left in [problems]; and a proposal that could not be opened,
* which is [onFailure]. Both stay on the form with the edits still in it.
*/
fun proposeEntry(
state: AcceptCuratedSuggestionUIState.Loaded,
onSuccess: (sessionId: String) -> Unit,
onInvalid: () -> Unit,
onFailure: () -> Unit
) {
// Guard against double submits. Two sessions over two versions of one
// entry would leave the list decided by whichever quorum finished last.
if (isActionPending.value) return
val template = state.schema.canonicalTemplate(
values = draft(state),
source = CuratedSuggestionRef.of(state.suggestion.entry),
)
problems = state.schema.verifyCanonical(template)
if (problems.isNotEmpty()) {
onInvalid.invoke()
return
}
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = state.localChatRoom,
userPublicKey = activeUserPublicKey,
kind = template.kind,
tags = template.tags,
content = template.content,
)
}.onFailure { error ->
logger.e("Failed to propose a canonical entry for ${state.schema.coordinate()}", error)
}.getOrNull()
viewModelScope.launch(Dispatchers.Main) {
if (session != null) {
onSuccess.invoke(session.id)
} else {
onFailure.invoke()
}
}
isActionPending.value = false
}
}
companion object {
private const val TAG = "AcceptCuratedSuggestionViewModel"
/** The tag of the field the form keeps whatever else it says: the entry's coordinate. */
private const val IDENTIFIER_TAG = "d"
/**
* The fields the form opens for editing: the ones the schema's form asks
* for, less the one writing to `d`. See the class note for why those two
* are kept as they were suggested.
*/
fun editableFields(state: AcceptCuratedSuggestionUIState.Loaded): List<CuratedField> =
state.schema.schema.formFields().filterNot { it.tag() == IDENTIFIER_TAG }
/**
* The fields the form shows but does not open: what the suggester's client
* filled in, and what the entry is addressed by. In the schema's order,
* and only the ones the suggestion has a value for, since a kept field
* with nothing in it has nothing to show.
*/
fun keptFields(state: AcceptCuratedSuggestionUIState.Loaded): List<CuratedField> {
val editable = editableFields(state).map { it.name }.toSet()
val values = state.suggestion.entry.values
return state.schema.schema.fields.filter { it.name !in editable && values[it.name].orEmpty().isNotEmpty() }
}
fun factory(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
identifier: String,
suggestionId: String,
initialAcceptCuratedSuggestionUIState: AcceptCuratedSuggestionUIState =
AcceptCuratedSuggestionUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AcceptCuratedSuggestionViewModel(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
identifier = identifier,
suggestionId = suggestionId,
initialAcceptCuratedSuggestionUIState = initialAcceptCuratedSuggestionUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}

View File

@@ -32,9 +32,11 @@ import press.mantra.compose.network.relays.EventPublishTransport
import press.mantra.compose.network.relays.NostrPublishResult
import press.mantra.compose.network.relays.RelayPool
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupRelayList
import press.mantra.compose.nostr.GroupRelaySet
import press.mantra.compose.nostr.Relays
import press.mantra.compose.nostr.curated.CuratedEntryEvent
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.ui.view.state.BroadcastGroupSignedEventUIState
@@ -67,9 +69,9 @@ import kotlin.time.Duration.Companion.milliseconds
*
* [defaultRelaysFor]: the group's own General list where it has agreed one, the
* app's publish set where it has not, plus the indexers for a relay list and the
* list's own relays for a schema, minus anything the group has blocked. The
* member can add and remove before sending, and the seeded list is a suggestion
* rather than a rule.
* list's own relays for a schema and for an entry into it, minus anything the
* group has blocked. The member can add and remove before sending, and the
* seeded list is a suggestion rather than a rule.
*/
class BroadcastGroupSignedEventViewModel(
val chatRoomId: String,
@@ -176,6 +178,7 @@ class BroadcastGroupSignedEventViewModel(
localChatRoom = localChatRoom,
signedEvent = signedEvent,
relayLists = chatRepository.groupRelayLists(chatRoomId),
schemas = chatRepository.groupCuratedSchemas(chatRoomId),
)
}
}
@@ -189,11 +192,17 @@ class BroadcastGroupSignedEventViewModel(
* loaded state straight in. Once and only once, so a member who emptied the
* list on purpose does not find it refilled by a state the view model re-emits.
*/
fun seedRelays(relayLists: List<GroupRelayList>, kind: Kind, tags: Array<Array<String>>, content: String) {
fun seedRelays(
relayLists: List<GroupRelayList>,
kind: Kind,
tags: Array<Array<String>>,
content: String,
schemas: List<GroupCuratedSchema> = emptyList(),
) {
if (isSeeded) return
isSeeded = true
relays.addAll(defaultRelaysFor(relayLists, kind, tags, content))
relays.addAll(defaultRelaysFor(relayLists, kind, tags, content, schemas))
}
fun clearAddFailure() {
@@ -377,6 +386,12 @@ class BroadcastGroupSignedEventViewModel(
* - A curated schema goes to the relays it names for its own replies, so a
* client reading suggestions there finds what they are replying to.
*
* - An entry into a curated list goes to the same relays as its schema,
* which the NIP makes a MUST: that is where the list's readers look,
* and where the queue screen reads the entry back to mark the
* suggestion it came from. The entry names its schema by coordinate,
* so the relays are read off the group's own copy of it in [schemas].
*
* And nothing goes to a relay the group has blocked, whatever the other
* lists say; that is what a blocked list is for.
*/
@@ -385,6 +400,7 @@ class BroadcastGroupSignedEventViewModel(
kind: Kind,
tags: Array<Array<String>> = emptyArray(),
content: String = "",
schemas: List<GroupCuratedSchema> = emptyList(),
): List<NormalizedRelayUrl> {
val general = relayLists.firstOrNull { it.set == GroupRelaySet.General }
?.relays.orEmpty()
@@ -399,12 +415,18 @@ class BroadcastGroupSignedEventViewModel(
emptyList()
}
val schemaRelays = if (kind == CuratedSchemaEvent.KIND) {
runCatching { CuratedSchemaEvent.read(tags, content).relays }
.getOrDefault(emptyList())
.mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
} else {
emptyList()
val schemaRelays = when (kind) {
CuratedSchemaEvent.KIND ->
runCatching { CuratedSchemaEvent.read(tags, content).relays }
.getOrDefault(emptyList())
.mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
CuratedEntryEvent.CANONICAL_KIND ->
schemas.firstOrNull { it.isRepliedToBy(tags) }
?.schema?.relays.orEmpty()
.mapNotNull { GroupRelaySet.relayUrlOrNull(it) }
else -> emptyList()
}
val blocked = relayLists.firstOrNull { it.set == GroupRelaySet.Blocked }

View File

@@ -0,0 +1,34 @@
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 AcceptCuratedSuggestionUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** The list the entry would go into, as the group last signed it. */
val schema: GroupCuratedSchema,
/**
* The suggestion being taken up, as the queue screen read it: the entry
* keyed by the schema's fields, who suggested it, and whether the group
* already has. The form starts as this, and the canonical entry points
* back at it.
*/
val suggestion: CuratedSuggestion,
/**
* Whether this device holds a share of the group's key.
*
* False leaves the form writable and the button inert, for the reason
* the other editors give: reading and editing cost nothing, and only a
* share-holder can open the session that would sign.
*/
val canSign: Boolean = false,
): AcceptCuratedSuggestionUIState
data class Error(
val message: String
): AcceptCuratedSuggestionUIState
data object Loading: AcceptCuratedSuggestionUIState
}

View File

@@ -2,6 +2,7 @@ package press.mantra.compose.ui.view.state
import press.mantra.compose.database.model.GroupSignedEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.nostr.GroupCuratedSchema
import press.mantra.compose.nostr.GroupRelayList
sealed interface BroadcastGroupSignedEventUIState {
@@ -16,6 +17,12 @@ sealed interface BroadcastGroupSignedEventUIState {
* `BroadcastGroupSignedEventViewModel.defaultRelaysFor`.
*/
val relayLists: List<GroupRelayList> = emptyList(),
/**
* The lists the group curates, for an entry into one of them: the NIP
* says a canonical entry goes to its schema's relays, and the entry
* names its schema by coordinate rather than carrying the relays itself.
*/
val schemas: List<GroupCuratedSchema> = emptyList(),
): BroadcastGroupSignedEventUIState
data class Error(