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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ fun BroadcastGroupSignedEventScreen(
|
||||
kind = signedEvent.kind,
|
||||
tags = signedEvent.tags,
|
||||
content = signedEvent.content,
|
||||
schemas = broadcastGroupSignedEventUIState.schemas,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user