feat(groups): the lists a group curates, and the one thing an edit may not change
`334e6dd1` gave a group a way to speak in its own name. This is the way it asks: kind 31889, a *curated schema event*, signed by the room's own key -- the definition of a list anyone may suggest entries to and only that key may accept them into. The protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md` there, with `curated-schema-events.md` as the guided tour), and this is its first half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply to it and are not read or written here yet. The section sits under Posts and closes the nostr-identity block, above Subgroups. The profile says who the group is, the relay lists say where to find it, the posts are what it says there -- and a schema is the other thing it publishes for strangers to answer. A post is the group speaking; a schema is the group asking, for entries to a list it will then curate by quorum under the same key. Everything in that block is signed by that key and read by people who were never in the room, and the divider that used to close it after the posts now closes it after these. **The protocol is ported tag for tag from the reference module, and the fixture is the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags mean. The point of publishing a schema is that another client can drive its form from it, so the one property worth pinning is that what bitcoin.mov published reads here and what this app signs would read there. `CuratedSchemaEventTest` parses the bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`, the domain, the relay -- checks every field of it, and round-trips it through `template` back to an equal schema. The field tag's seventh position is a JSON blob, and a key this app does not know is kept in `CuratedFieldConfig.others` and written back, because other clients legitimately add their own and an edit here must not strip what one of them meant. **Rejected rather than repaired, with the one repair the NIP requires.** A schema is what suggestions are checked against, so a reader that patched a broken one would be accepting entries against a form nobody signed. `CuratedSchema.problems` is the NIP's rejection list and nothing more -- a missing identifier, title, name or description, an over-long one, a visibility that is absent or not one of the three words, no `field` tags at all, a picture that is not https, a domain that is not a hostname, a relay that is not a relay -- and it is the *same* list whether the schema was typed into the editor or read off a relay: the editor refuses to propose what a reader would refuse to show. The exception is `normalized`, which forces a field writing to `d` and one writing to `title` into place, both required, because that is the rule the NIP states so that no schema, wherever it came from, can talk a client into accepting untitled or unaddressable entries. The "no fields" check runs before that repair, on what the publisher said. **Visibility is never guessed, so it is nullable.** A list that does not say who may suggest to it is not one a client should act on, and the reference makes the same point by reporting a missing visibility as a violation rather than defaulting it. An enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null for a schema that does not say, `VisibilityMissing` is the problem it raises, and `template` writes no `visibility` tag for a null one -- which every reader, this one included, then refuses. The editor never produces one; only an event off a relay can be missing it. **One per identifier, newest wins.** The third ordering in this family. A profile is one replaceable event and the newest wins; posts are not replaceable and accumulate; a schema is addressable, so a group may curate several lists and each is its own coordinate `31889:<room>:<d>`. `GroupCuratedSchema.newestPerListAmong` groups by identifier, keeps the newest within each -- an edit is a newer event under the same `d` -- and orders the lists most recently signed first, with the event id breaking every tie so two devices reading the same events in different orders agree. **An edit keeps the identifier, whatever the field says.** The identifier is the coordinate every suggestion to the list replies to. Changing it would not edit the list but start a second one with an empty queue and orphan the first's, so the identifier field is read-only on an edit, says why under itself, and `proposeSchema` takes it from the existing schema rather than from the field even so -- a screen not drawing something is not a guard. A new list is "Add schema" on the group's screen, which is why the section has both a per-card way in and a button: each schema is edited on its own. **A new list starts filled in, and with the group's own relays.** The two mandatory fields are put on the form before anything is typed, rather than left for `normalized` to add at signing time, because a form that showed an empty list and then signed two fields would be lying about what it proposed. The relays are seeded from the group's NIP-65 write relays -- where its canonical entries would be read from -- falling back to `GroupRelaySet.General.defaults()`, which is this build's own relay and the same thing a group opening the relay editor for the first time is shown. A schema naming no relays is one whose suggestions could go anywhere, so the seed is worth getting right; the NIP says the same, and a client is allowed to fall back to its own list only when the schema names none. **What the editor lets a group sign is held to the app's rule, not the NIP's.** A `relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`, not this machine -- because the group is about to put a quorum's signature on it for strangers to publish to, which is exactly the argument that rule was written for. The duplicate check compares normalized forms, since a relay signed into an existing schema is kept as the group wrote it and the normalizer adds a trailing slash: the same host with and without one is one relay. The first version compared strings and would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it. Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub (with or without `nostr:`) or 64 hex characters and nothing else, and only offered when the visibility gives them something to be. **A field is edited on a sheet of its own, and a mandatory one cannot stop being one.** A field has a dozen settings, and thirteen of them inline would be a screen nobody could find anything on. The sheet offers the positional parts first -- name, label, placeholder, type, required -- and then only the config keys the chosen type gives a meaning to: `min` and a numeric `max` for the three numeric types, `options` for an enum, `https` for a url. A setting the event gives no meaning to would be written into the schema and mean nothing, so when the type changes away from one it is not kept. For a field writing to `d` or `title` the tag is pinned and the requirement is on and disabled, because `normalized` would only put the field back if the sheet let it be moved or relaxed, and offering a change the event will not carry is worse than not offering it. A field name is one word and belongs to one field, and a rename follows through to the `require-any` rules that named it, since a rule naming a field that no longer exists would silently never be satisfied. The rules themselves are a chip per field, selected for the ones in the rule; one with fewer than two names says nothing and is dropped from the draft rather than refused. **The transcript arm returns null rather than `unsupported`.** The opposite call to the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant, and a schema rumor from a member -- or one the room signed that no client could act on -- is identity plumbing, the position the kind:0 and relay-list arms are in. Parsed as well as verified, so the transcript and the group's screen agree about which events are lists: `GroupCuratedSchema.of` refuses the same event both places. The line names the list rather than describing the schema, because what a reader of the room needs to know is that the group now curates -- or has changed the form for -- a list called so-and-so, and the fields are on the group's screen. `TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a `RitualNotice` and previewed like the other three. **The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for 31889, which the last three group features did not add for their kinds. A member deciding whether to sign this is agreeing to take suggestions from whoever the visibility admits and to be the one key that accepts them, and the event's content is only the description -- so "Event of kind 31889" over a sentence would leave them signing a list without being told its name, who may suggest to it, or how much an entry asks for. The summary is those three. **The sheet's switches carry their label as a description.** A screen reader otherwise announces "switch, off" beside a sentence it has already read past. It is also what the layout test reaches the switch by, which is how it was noticed. **The heading is "Curated schemas"**, not the kind's name. The audit enforces sentence case, and the sibling headings name the thing -- "Posts", "Relays" -- rather than the event. **And the caveat that bites hardest here of all: nothing has reached a relay.** The same limit `GroupPost` states, and a schema is the one statement whose *entire* purpose is to be replied to by strangers. Until group-signed events are published, a list defined here has no queue. The relays are signed into the schema ready for that, which is also why the NIP puts them in the event rather than in a config file. 51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the read, the round trip, the tag order, a config key this app does not know, a malformed blob costing a field its constraints and not its life, every rejection rule, the description falling back to content, the mandatory-field repair, and domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums through the same shape `FrostSigningManager.advance` runs -- the room's signature reads, a stranger's does not, a forged author does not, and a signed-but-unusable schema is refused -- and pins one-per-identifier ordering and the id tie-break. `EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock, the mandatory fields, renames following through to rules, the pubkey and relay rules, the normalized duplicate check, and the button proposing nothing for an untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in `GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a type its own settings, and the section's place on the screen, its empty state, a member with no share reading schemas they cannot edit, and its absence in a NIP-17 room. 1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest` -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@930d37c81e
This commit is contained in:
1 parent
e74592a48e
commit
f8e5d3610d
21 files changed
+4832
-2
No files matched your search
@@ -457,4 +457,106 @@
|
||||
<string name="could_not_ask_the_group_to_sign_this_post">Couldn't ask the group to sign this post.</string>
|
||||
<string name="posted_on">Posted %1$s</string>
|
||||
<string name="a_reply">A reply</string>
|
||||
<string name="curated_schemas">Curated schemas</string>
|
||||
<string name="this_group_has_not_published_a_curated_schema_yet">This group hasn't published a curated schema yet. A schema defines a list anyone can suggest entries to and only the group can curate.</string>
|
||||
<string name="add_schema">Add schema</string>
|
||||
<string name="edit_schema">Edit schema</string>
|
||||
<string name="propose_schema">Propose schema</string>
|
||||
<string name="fields_colon">Fields: %1$s</string>
|
||||
<string name="visibility_public">Public</string>
|
||||
<string name="visibility_closed">Closed</string>
|
||||
<string name="visibility_private">Private</string>
|
||||
<string name="visibility_public_purpose">Anyone may suggest an entry, and anyone may read the list.</string>
|
||||
<string name="visibility_closed_purpose">Only the group and the suggesters below may suggest. Anyone may read the list.</string>
|
||||
<string name="visibility_private_purpose">Only the group and the suggesters below may suggest, and clients show the list to them alone. Relays are open, so this is a convention rather than a secret.</string>
|
||||
<string name="what_an_entry_in_this_list_may_contain">What an entry in this list may contain, and who may suggest one</string>
|
||||
<string name="the_group_signs_a_schema_so_it_takes_a_quorum">The group signs a curated schema, so it takes a quorum. Suggestions reply to it, and only the group can accept them into the list.</string>
|
||||
<string name="this_group_has_no_shared_key_to_sign_a_schema">This group has no shared key, so it cannot sign a curated schema. Run a shared key ceremony first.</string>
|
||||
<string name="could_not_ask_the_group_to_sign_this_schema">Couldn't ask the group to sign this schema.</string>
|
||||
<string name="identifier">Identifier</string>
|
||||
<string name="eg_bitcoin_mov">eg. bitcoin.mov</string>
|
||||
<string name="the_identifier_names_this_list_in_every_reply">The identifier names this list in every reply to it, so it can't change. Add a schema to start another list.</string>
|
||||
<string name="title">Title</string>
|
||||
<string name="eg_bitcoin_mov_suggestion">eg. bitcoin.mov suggestion</string>
|
||||
<string name="description">Description</string>
|
||||
<string name="eg_fields_for_a_bitcoin_mov_entry">eg. Fields for a bitcoin.mov entry: a film, with at least one link to watch it</string>
|
||||
<string name="visibility">Visibility</string>
|
||||
<string name="domain">Domain</string>
|
||||
<string name="eg_example_com">eg. example.com</string>
|
||||
<string name="a_domain_replaces_the_name_wherever_the_list_is_shown">A domain replaces the name wherever the list is shown. It's a claim rather than proof, so only set one the group controls.</string>
|
||||
<string name="fields">Fields</string>
|
||||
<string name="add_field">Add field</string>
|
||||
<string name="required">Required</string>
|
||||
<string name="optional">Optional</string>
|
||||
<string name="derived">Derived</string>
|
||||
<string name="at_least_one_of">At least one of</string>
|
||||
<string name="add_rule">Add rule</string>
|
||||
<string name="pick_two_or_more_fields_at_least_one_of_which_an_entry_must_have">Pick two or more fields, at least one of which an entry must have. A rule with fewer than two is dropped.</string>
|
||||
<string name="suggesters">Suggesters</string>
|
||||
<string name="pubkeys_besides_the_group_that_may_suggest_to_this_list">Pubkeys besides the group that may suggest to this list.</string>
|
||||
<string name="npub_or_hex_pubkey">npub or hex pubkey</string>
|
||||
<string name="add_suggester">Add suggester</string>
|
||||
<string name="that_is_not_a_pubkey">That isn't a pubkey. Paste an npub or 64 hex characters.</string>
|
||||
<string name="that_pubkey_is_already_a_suggester">That pubkey is already a suggester.</string>
|
||||
<string name="where_suggestions_to_this_list_are_published">Where suggestions and entries are published to and read from. Signed into the schema, so a reply can't be sent anywhere else.</string>
|
||||
<string name="no_relays_named_so_a_client_will_pick_its_own">No relays named, so a client will pick its own.</string>
|
||||
<string name="new_field">New field</string>
|
||||
<string name="edit_field">Edit field</string>
|
||||
<string name="field_name">Field name</string>
|
||||
<string name="eg_watchurl">eg. watchUrl</string>
|
||||
<string name="label">Label</string>
|
||||
<string name="eg_watch_reference_url">eg. Watch / reference url</string>
|
||||
<string name="placeholder">Placeholder</string>
|
||||
<string name="eg_https_youtube_com_watch">eg. https://youtube.com/watch?v=…</string>
|
||||
<string name="type">Type</string>
|
||||
<string name="an_entry_must_have_this_field">An entry must have this field</string>
|
||||
<string name="writes_to_tag">Writes to tag</string>
|
||||
<string name="defaults_to_the_field_name_content_means_the_body">Defaults to the field name. "content" means the event's body.</string>
|
||||
<string name="marker">Marker</string>
|
||||
<string name="eg_watch">eg. watch</string>
|
||||
<string name="the_tag_s_third_element_which_tells_fields_sharing_a_tag_apart">The tag's third element, which tells fields sharing a tag apart.</string>
|
||||
<string name="maximum_characters">Maximum characters</string>
|
||||
<string name="maximum_value">Maximum value</string>
|
||||
<string name="minimum_value">Minimum value</string>
|
||||
<string name="options">Options</string>
|
||||
<string name="one_per_line_or_separated_by_commas">One per line, or separated by commas.</string>
|
||||
<string name="https_only">https only</string>
|
||||
<string name="may_appear_more_than_once">May appear more than once</string>
|
||||
<string name="pattern">Pattern</string>
|
||||
<string name="a_regular_expression_the_whole_value_must_match">A regular expression the whole value must match.</string>
|
||||
<string name="filled_in_by_the_app_never_asked_for">Filled in by the app, never asked for</string>
|
||||
<string name="hint">Hint</string>
|
||||
<string name="helper_text_under_the_input">Helper text under the input.</string>
|
||||
<string name="done">Done</string>
|
||||
<string name="remove_field">Remove field</string>
|
||||
<string name="a_field_needs_a_name">A field needs a name.</string>
|
||||
<string name="a_field_name_is_one_word">A field name is one word, with no spaces.</string>
|
||||
<string name="a_field_named_that_already_exists">A field named that already exists.</string>
|
||||
<string name="an_identifier_is_required">An identifier is required.</string>
|
||||
<string name="the_identifier_must_be_at_most_n_characters">The identifier must be at most %1$s characters.</string>
|
||||
<string name="a_title_is_required">A title is required.</string>
|
||||
<string name="the_title_must_be_at_most_n_characters">The title must be at most %1$s characters.</string>
|
||||
<string name="a_name_is_required">A name is required.</string>
|
||||
<string name="the_name_must_be_at_most_n_characters">The name must be at most %1$s characters.</string>
|
||||
<string name="a_description_is_required">A description is required.</string>
|
||||
<string name="the_description_must_be_at_most_n_characters">The description must be at most %1$s characters.</string>
|
||||
<string name="say_who_may_suggest_to_this_list">Say who may suggest to this list.</string>
|
||||
<string name="the_picture_must_be_an_https_url">The picture must be an https url.</string>
|
||||
<string name="that_is_not_a_domain_name">That isn't a domain name.</string>
|
||||
<string name="a_schema_needs_at_least_one_field">A schema needs at least one field.</string>
|
||||
<string name="one_of_the_relays_is_not_a_relay_address">One of the relays isn't a relay address.</string>
|
||||
<string name="eg_2100">eg. 2100</string>
|
||||
<string name="eg_200">eg. 200</string>
|
||||
<string name="eg_1900">eg. 1900</string>
|
||||
<string name="eg_movie_documentary_short">eg. movie, documentary, short</string>
|
||||
<string name="eg_a_za_z_2_8">eg. [A-Za-z]{2,8}</string>
|
||||
<string name="field_type_text">Text</string>
|
||||
<string name="field_type_longtext">Long text</string>
|
||||
<string name="field_type_token">Token</string>
|
||||
<string name="field_type_url">Url</string>
|
||||
<string name="field_type_image">Image</string>
|
||||
<string name="field_type_enum">One of a list</string>
|
||||
<string name="field_type_year">Year</string>
|
||||
<string name="field_type_duration">Duration in seconds</string>
|
||||
<string name="field_type_number">Number</string>
|
||||
</resources>
|
||||
@@ -22,7 +22,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
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.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import press.mantra.compose.nostr.dkg.DkgRitualEvents
|
||||
@@ -434,10 +436,22 @@ data class ChatMessage(
|
||||
*/
|
||||
const val TYPE_GROUP_POST_SIGNED = "groupPostSigned"
|
||||
|
||||
/**
|
||||
* The group signed the schema of a list it curates.
|
||||
*
|
||||
* A system line like the other three, and it names the list rather than
|
||||
* describing the schema: what a reader of the room needs to know is that
|
||||
* the group now curates -- or has changed the form for -- a list called
|
||||
* so-and-so, and the schema itself is on the group's screen where its
|
||||
* fields can be read.
|
||||
*/
|
||||
const val TYPE_GROUP_SCHEMA_SIGNED = "groupSchemaSigned"
|
||||
|
||||
val GROUP_IDENTITY_TYPES = setOf(
|
||||
TYPE_GROUP_PROFILE_SIGNED,
|
||||
TYPE_GROUP_RELAYS_SIGNED,
|
||||
TYPE_GROUP_POST_SIGNED,
|
||||
TYPE_GROUP_SCHEMA_SIGNED,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -1535,6 +1549,36 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
|
||||
// The group defining a list it curates: the kind 31889 that
|
||||
// suggestions from anyone reply to and only the group's own key can
|
||||
// accept into the list.
|
||||
//
|
||||
// Verified and parsed, and null on either failing, the way the
|
||||
// profile arm is. A rumor of this kind from a member is not the
|
||||
// group curating anything, and a schema the room signed but that no
|
||||
// client could act on -- no visibility, no fields -- is not worth a
|
||||
// line saying it did. `GroupCuratedSchema` refuses the same event
|
||||
// on the group's screen, so the transcript and the screen agree.
|
||||
CuratedSchemaEvent.KIND -> {
|
||||
val curated = GroupCuratedSchema.of(
|
||||
signedEvent = GroupSignedEvent.fromEvent(event, chatRoomId = groupId),
|
||||
chatRoomId = groupId,
|
||||
) ?: return null
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = TYPE_GROUP_SCHEMA_SIGNED,
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "The group signed the curated schema for " +
|
||||
curated.schema.displayName()
|
||||
)
|
||||
}
|
||||
|
||||
else -> unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -10,7 +10,9 @@ import press.mantra.compose.managers.GroupKeyStateManager
|
||||
import press.mantra.compose.managers.MarmotGroupCreation
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
@@ -170,6 +172,17 @@ class DatabaseChatRepository(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun groupCuratedSchemas(chatRoomId: String): List<GroupCuratedSchema> = try {
|
||||
GroupCuratedSchema.newestPerListAmong(
|
||||
signedEvents = database.groupSignedEventDao()
|
||||
.getByChatRoomIdAndKind(chatRoomId, CuratedSchemaEvent.KIND),
|
||||
chatRoomId = chatRoomId,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error reading the curated schemas of $chatRoomId", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = try {
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.nostr.curated.CuratedSchema
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A curated schema the group signed: a list it curates, as the group defined it.
|
||||
*
|
||||
* Kind 31889 -- see [CuratedSchemaEvent] -- authored by the room's own key. That
|
||||
* makes the group the curator of the list the schema describes: anyone may reply
|
||||
* to it with a suggestion, and only an event signed by the same key counts as the
|
||||
* list itself. For a group that means the same thing everything else on its screen
|
||||
* means: a quorum decides what goes in.
|
||||
*
|
||||
* ### Several per group, one per identifier
|
||||
*
|
||||
* Unlike a profile, of which a group has one, a group may curate several lists,
|
||||
* and each is its own coordinate `31889:<room>:<d>`. So the reading is per
|
||||
* identifier: among the events sharing a `d` the newest wins, the way it does for
|
||||
* a profile, because the kind is addressable and re-signing one is how a schema
|
||||
* is edited. Two different identifiers are two lists and both are shown.
|
||||
*
|
||||
* Read off [GroupSignedEvent] and gated on [GroupSignedEvent.verifies], for the
|
||||
* reasons `GroupNostrProfile` gives at length: a group-signed event is not a
|
||||
* `NostrEvent`, and only an event the room itself signed is the group speaking.
|
||||
*
|
||||
* ### Nothing here has reached a relay
|
||||
*
|
||||
* The same caveat as the profile, the relay lists and the posts. A schema is the
|
||||
* one statement here that exists to be *replied to* by strangers, so until it is
|
||||
* published the list it defines has no queue -- the relays it names say where
|
||||
* suggestions would go, and nothing yet puts the schema there for them to find.
|
||||
*/
|
||||
data class GroupCuratedSchema(
|
||||
/** The group's statement, whole, with the signature that makes it one. */
|
||||
val signedEvent: GroupSignedEvent,
|
||||
/** What the statement says, already checked and normalized. */
|
||||
val schema: CuratedSchema,
|
||||
) {
|
||||
/** The group's nostr identity, which for a derived room is also its id. */
|
||||
val publicKey: HexKey get() = signedEvent.publicKey
|
||||
|
||||
/** When the group signed it, which is what decides the newest of two. */
|
||||
val signedAt: Instant get() = signedEvent.createdAt
|
||||
|
||||
/** The schema's `d`, and the last part of its coordinate. */
|
||||
val identifier: String get() = schema.identifier
|
||||
|
||||
/** What a suggestion's `a` root names to reply to this list. */
|
||||
fun coordinate(): String = CuratedSchemaEvent.coordinate(publicKey, identifier)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The reading of one signed event, or null when it is not one of these.
|
||||
*
|
||||
* Three ways to be null and they are all the same refusal: the wrong
|
||||
* kind, a signature that does not check out as [chatRoomId]'s, or tags
|
||||
* that do not make a usable schema. A caller gets a schema the group
|
||||
* really signed and a client could really act on, or gets nothing.
|
||||
*/
|
||||
fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupCuratedSchema? {
|
||||
if (signedEvent.kind != CuratedSchemaEvent.KIND) return null
|
||||
if (!signedEvent.verifies()) return null
|
||||
|
||||
val schema = CuratedSchemaEvent.parse(signedEvent.toEvent()) ?: return null
|
||||
|
||||
return GroupCuratedSchema(signedEvent = signedEvent, schema = schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest schema per identifier [chatRoomId] signed among
|
||||
* [signedEvents], most recently signed list first.
|
||||
*
|
||||
* Newest per identifier because the kind is addressable and an edit is a
|
||||
* newer event under the same `d`; newest list first so that an edit
|
||||
* surfaces, and the id breaking every tie so that two devices reading the
|
||||
* same events in different orders show the same thing.
|
||||
*/
|
||||
fun newestPerListAmong(
|
||||
signedEvents: List<GroupSignedEvent>,
|
||||
chatRoomId: String,
|
||||
): List<GroupCuratedSchema> =
|
||||
signedEvents
|
||||
.mapNotNull { of(it, chatRoomId) }
|
||||
.groupBy { it.identifier }
|
||||
.values
|
||||
.map { versions ->
|
||||
versions.maxWith(compareBy({ it.signedAt }, { it.signedEvent.id }))
|
||||
}
|
||||
.sortedWith(
|
||||
compareByDescending<GroupCuratedSchema> { it.signedAt }
|
||||
.thenByDescending { it.signedEvent.id }
|
||||
)
|
||||
|
||||
/**
|
||||
* The event to ask the group to sign for [schema].
|
||||
*
|
||||
* Built from the schema alone -- see [CuratedSchemaEvent.template] for
|
||||
* why an edit does not carry the last event's tags forward.
|
||||
*/
|
||||
fun template(schema: CuratedSchema): EventTemplate<*> = CuratedSchemaEvent.template(schema)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package press.mantra.compose.nostr.curated
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import press.mantra.compose.network.serialization.CommonJson
|
||||
|
||||
/**
|
||||
* The definition of a curated list: what an entry may contain, who may suggest
|
||||
* one, and whose list it is.
|
||||
*
|
||||
* This is the document the other two curated kinds are checked against. A
|
||||
* suggestion (kind 31888) is a reply to it that has to satisfy its fields; a
|
||||
* canonical entry (kind 31890) is a suggestion its curator republished under
|
||||
* their own key. The list is the set of canonical entries and the suggestions
|
||||
* are its queue -- and this is the form both are filled in against, published
|
||||
* so that another client can render the same form without shipping this code.
|
||||
*
|
||||
* The curator's pubkey is the namespace. `31889:<pubkey>:<identifier>` names
|
||||
* one schema and no other, so nothing global is claimed and nothing can be
|
||||
* squatted. Here the curator is a group: the schema is signed by the room's own
|
||||
* key, like its profile and its posts, and it is the group -- by quorum -- that
|
||||
* accepts a suggestion into the list.
|
||||
*
|
||||
* The protocol is written up in `bitcoin.mov/docs/NIP.md`, and the shape here
|
||||
* follows its reference module tag for tag so that a schema this app signs is
|
||||
* one that site's form can be driven by, and one it publishes reads here.
|
||||
*
|
||||
* ### What is checked, and where
|
||||
*
|
||||
* [problems] is the list of reasons a schema is unusable, and it is the same
|
||||
* list whether the schema was typed into the editor or read off a relay: the
|
||||
* NIP's rejection rules, no more. A reader rejects rather than repairs --
|
||||
* relays carry malformed and hostile events, and a half-parsed form is worse
|
||||
* than a missing one -- and the editor refuses to propose what a reader would
|
||||
* refuse to show.
|
||||
*
|
||||
* [normalized] is the one repair the NIP requires. Every list has a field
|
||||
* writing to `d` and one writing to `title`, both required, and a schema that
|
||||
* omits or relaxes either gets them put back -- so no schema, wherever it came
|
||||
* from, can talk a client into accepting untitled or unaddressable entries.
|
||||
*/
|
||||
data class CuratedSchema(
|
||||
/** The `d` tag: the schema's identifier, and the last part of its coordinate. */
|
||||
val identifier: String,
|
||||
/** Labels the event -- "bitcoin.mov suggestion". */
|
||||
val title: String,
|
||||
/** The list's identity, what people call it -- "bitcoin.mov". */
|
||||
val name: String,
|
||||
/** What the list is for. Mirrored into the event's content for generic clients. */
|
||||
val description: String,
|
||||
/**
|
||||
* Who may suggest, or null when the schema does not say.
|
||||
*
|
||||
* Null is never defaulted away: a list that does not say who may suggest to
|
||||
* it is not one a client should act on, so an absent or unrecognised
|
||||
* visibility is a [CuratedSchemaProblem.VisibilityMissing] rather than a
|
||||
* public list by accident. The editor always has one; only a schema read off
|
||||
* a relay can be missing it.
|
||||
*/
|
||||
val visibility: CuratedVisibility?,
|
||||
/** An https image for the list, or null. */
|
||||
val picture: String? = null,
|
||||
/**
|
||||
* A hostname the list publishes under, or null. Shown in place of [name]
|
||||
* wherever the list is named, which is why it is a claim worth being careful
|
||||
* with: anyone can put any domain in a tag. See [displayName].
|
||||
*/
|
||||
val domain: String? = null,
|
||||
/** The kind suggestions are published under. There is only one. */
|
||||
val suggestionKind: Kind = CuratedSchemaEvent.SUGGESTION_KIND,
|
||||
val fields: List<CuratedField> = emptyList(),
|
||||
/**
|
||||
* Groups of field names of which at least one must be present on an entry.
|
||||
*
|
||||
* How a list says "a watch link or an IMDb link" without making either one
|
||||
* required. A group of fewer than two names says nothing and is dropped.
|
||||
*/
|
||||
val requireAny: List<List<String>> = emptyList(),
|
||||
/**
|
||||
* Pubkeys allowed to suggest on a closed or private list, besides the
|
||||
* curator. The `p` tags. Meaningless on a public list, where anyone may.
|
||||
*/
|
||||
val suggesters: List<HexKey> = emptyList(),
|
||||
/**
|
||||
* Where the list lives: the relays suggestions and canonical entries are
|
||||
* published to and read from. Signed into the event so a client that finds
|
||||
* the schema anywhere knows where a reply belongs and cannot be sent
|
||||
* elsewhere by an unsigned config file.
|
||||
*/
|
||||
val relays: List<String> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* What to call the list on screen: the domain when it has one, else the
|
||||
* name. The domain is the stronger identity because it is the one part of
|
||||
* this that can be checked against the outside world, NIP-05 style -- and
|
||||
* nothing here does check it, so it is a label rather than a badge.
|
||||
*/
|
||||
fun displayName(): String = domain ?: name
|
||||
|
||||
/** Fields a form should prompt for. Derived ones are filled in by the client. */
|
||||
fun formFields(): List<CuratedField> = fields.filterNot { it.config.derived == true }
|
||||
|
||||
/**
|
||||
* The same schema with the two mandatory fields forced into place.
|
||||
*
|
||||
* A field writing to `d` and one writing to `title`, both required, in that
|
||||
* order at the front when they were missing. A schema that already has them
|
||||
* is unchanged apart from the requirement, so running this twice is running
|
||||
* it once.
|
||||
*/
|
||||
fun normalized(): CuratedSchema {
|
||||
val fields = fields.toMutableList()
|
||||
|
||||
for ((tag, fallback) in CuratedField.MANDATORY) {
|
||||
val index = fields.indexOfFirst { it.tag() == tag }
|
||||
if (index == -1) {
|
||||
fields.add(0, fallback)
|
||||
} else if (!fields[index].isRequired) {
|
||||
fields[index] = fields[index].copy(isRequired = true)
|
||||
}
|
||||
}
|
||||
|
||||
return copy(fields = fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every reason this is not a usable schema, or nothing.
|
||||
*
|
||||
* The NIP's rejection rules and only those: a client MUST refuse a schema
|
||||
* missing its identifier, title, name, description or visibility, one whose
|
||||
* visibility is not one of the three words, one with no fields, and one
|
||||
* whose picture is not https, whose domain is not a hostname, or whose relay
|
||||
* is not a relay. The length caps are the reference module's, so a schema
|
||||
* this app signs is one that site accepts.
|
||||
*
|
||||
* Checked on the schema as written, before [normalized] -- "no fields" is
|
||||
* about what the publisher said, not about what a reader would put back.
|
||||
*/
|
||||
fun problems(): List<CuratedSchemaProblem> = buildList {
|
||||
fun text(value: String, cap: Int, missing: CuratedSchemaProblem, tooLong: CuratedSchemaProblem) {
|
||||
if (value.isBlank()) add(missing) else if (value.length > cap) add(tooLong)
|
||||
}
|
||||
|
||||
text(identifier, CuratedSchemaEvent.MAX_IDENTIFIER, CuratedSchemaProblem.IdentifierMissing, CuratedSchemaProblem.IdentifierTooLong)
|
||||
text(title, CuratedSchemaEvent.MAX_TITLE, CuratedSchemaProblem.TitleMissing, CuratedSchemaProblem.TitleTooLong)
|
||||
text(name, CuratedSchemaEvent.MAX_NAME, CuratedSchemaProblem.NameMissing, CuratedSchemaProblem.NameTooLong)
|
||||
text(description, CuratedSchemaEvent.MAX_DESCRIPTION, CuratedSchemaProblem.DescriptionMissing, CuratedSchemaProblem.DescriptionTooLong)
|
||||
|
||||
if (visibility == null) add(CuratedSchemaProblem.VisibilityMissing)
|
||||
|
||||
picture?.let {
|
||||
if (!CuratedSchemaEvent.isHttpsUrl(it) || it.length > CuratedSchemaEvent.MAX_URL) {
|
||||
add(CuratedSchemaProblem.PictureNotHttps)
|
||||
}
|
||||
}
|
||||
|
||||
domain?.let {
|
||||
if (!CuratedSchemaEvent.isDomain(it)) add(CuratedSchemaProblem.DomainInvalid)
|
||||
}
|
||||
|
||||
if (fields.isEmpty()) add(CuratedSchemaProblem.NoFields)
|
||||
|
||||
if (relays.any { !CuratedSchemaEvent.isRelayUrl(it) }) add(CuratedSchemaProblem.RelayInvalid)
|
||||
}
|
||||
|
||||
fun isUsable(): Boolean = problems().isEmpty()
|
||||
}
|
||||
|
||||
/** A reason a [CuratedSchema] cannot be published or believed. See [CuratedSchema.problems]. */
|
||||
enum class CuratedSchemaProblem {
|
||||
IdentifierMissing,
|
||||
IdentifierTooLong,
|
||||
TitleMissing,
|
||||
TitleTooLong,
|
||||
NameMissing,
|
||||
NameTooLong,
|
||||
DescriptionMissing,
|
||||
DescriptionTooLong,
|
||||
VisibilityMissing,
|
||||
PictureNotHttps,
|
||||
DomainInvalid,
|
||||
NoFields,
|
||||
RelayInvalid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may suggest to a list, and who it is meant for.
|
||||
*
|
||||
* Suggesting only. Curation is never delegated: a public list still has exactly
|
||||
* one curator, and the `p` tags on a closed list name extra *suggesters*.
|
||||
*
|
||||
* Relays are open, so [Private] is a convention clients follow rather than an
|
||||
* encryption anybody enforces. Nothing secret belongs in a private list.
|
||||
*/
|
||||
enum class CuratedVisibility(val value: String) {
|
||||
/** Anyone may suggest; anyone may read. */
|
||||
Public("public"),
|
||||
|
||||
/** The curator and the `p` tags may suggest; anyone may read. */
|
||||
Closed("closed"),
|
||||
|
||||
/** The curator and the `p` tags may suggest; clients should show it only to them. */
|
||||
Private("private"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Case-insensitive, trimmed, and null for anything but the three words. */
|
||||
fun parse(value: String): CuratedVisibility? =
|
||||
value.trim().lowercase().let { word -> entries.firstOrNull { it.value == word } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What kind of value a field takes. One validation rule each, in the NIP's
|
||||
* table; a form picks its input from this.
|
||||
*
|
||||
* [isNumeric] decides what `max` means: a maximum *value* for these, a maximum
|
||||
* *length* for everything else. The double duty is safe because a field is one
|
||||
* or the other, never both.
|
||||
*/
|
||||
enum class CuratedFieldType(val value: String, val isNumeric: Boolean = false) {
|
||||
/** Single-line string, length-capped. */
|
||||
Text("text"),
|
||||
|
||||
/** Multi-line string. */
|
||||
LongText("longtext"),
|
||||
|
||||
/** One word, no whitespace -- `imdb:tt2821314`. */
|
||||
Token("token"),
|
||||
|
||||
/** An http(s) URL; `https` in the config narrows it. */
|
||||
Url("url"),
|
||||
|
||||
/** A URL that must be https, since browsers block insecure images. */
|
||||
Image("image"),
|
||||
|
||||
/** One of the config's `options`. */
|
||||
Enum("enum"),
|
||||
|
||||
/** An integer year, bounded by `min` and `max`. */
|
||||
Year("year", isNumeric = true),
|
||||
|
||||
/** A positive integer number of seconds, bounded by `min` and `max`. */
|
||||
Duration("duration", isNumeric = true),
|
||||
|
||||
/** An integer, bounded by `min` and `max`. */
|
||||
Number("number", isNumeric = true),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun parse(value: String): CuratedFieldType? = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything about a field beyond its name, type, optionality, placeholder and
|
||||
* label -- the JSON object in the seventh position of a `field` tag.
|
||||
*
|
||||
* Null throughout means "not said", and a key that is not said is not written,
|
||||
* so a config with nothing in it is `{}`. [others] carries keys this app does
|
||||
* not know, so editing a field another client wrote does not silently strip
|
||||
* what that client meant by it.
|
||||
*/
|
||||
data class CuratedFieldConfig(
|
||||
/** The tag the field writes to. Defaults to the field's name; [CONTENT_TAG] means the body. */
|
||||
val tag: String? = null,
|
||||
/** A marker written as the tag's third element -- `["r", url, "watch"]`. */
|
||||
val marker: String? = null,
|
||||
/** Maximum characters for text-ish types, maximum value for numeric ones. */
|
||||
val max: Long? = null,
|
||||
/** Minimum value. Numeric types only. */
|
||||
val min: Long? = null,
|
||||
/** The allowed values of an enum. */
|
||||
val options: List<String>? = null,
|
||||
/** Require https on a url. */
|
||||
val https: Boolean? = null,
|
||||
/** The tag may appear more than once. */
|
||||
val repeat: Boolean? = null,
|
||||
/** A regular expression the trimmed value must match end to end. */
|
||||
val pattern: String? = null,
|
||||
/** The client fills the value in and must not prompt for it. */
|
||||
val derived: Boolean? = null,
|
||||
/** Helper text under the input. */
|
||||
val hint: String? = null,
|
||||
/** Keys this app does not know, kept as they came. */
|
||||
val others: Map<String, JsonElement> = emptyMap(),
|
||||
) {
|
||||
/** The blob as it goes into the tag. Known keys first, in the NIP's order, then [others]. */
|
||||
fun toJson(): String = buildJsonObject {
|
||||
tag?.let { put(KEY_TAG, it) }
|
||||
marker?.let { put(KEY_MARKER, it) }
|
||||
max?.let { put(KEY_MAX, it) }
|
||||
min?.let { put(KEY_MIN, it) }
|
||||
options?.let { put(KEY_OPTIONS, JsonArray(it.map(::JsonPrimitive))) }
|
||||
https?.let { put(KEY_HTTPS, it) }
|
||||
repeat?.let { put(KEY_REPEAT, it) }
|
||||
pattern?.let { put(KEY_PATTERN, it) }
|
||||
derived?.let { put(KEY_DERIVED, it) }
|
||||
hint?.let { put(KEY_HINT, it) }
|
||||
others.forEach { (key, value) -> put(key, value) }
|
||||
}.toString()
|
||||
|
||||
companion object {
|
||||
/** The `tag` value meaning the event's content rather than a tag. */
|
||||
const val CONTENT_TAG = "content"
|
||||
|
||||
private const val KEY_TAG = "tag"
|
||||
private const val KEY_MARKER = "marker"
|
||||
private const val KEY_MAX = "max"
|
||||
private const val KEY_MIN = "min"
|
||||
private const val KEY_OPTIONS = "options"
|
||||
private const val KEY_HTTPS = "https"
|
||||
private const val KEY_REPEAT = "repeat"
|
||||
private const val KEY_PATTERN = "pattern"
|
||||
private const val KEY_DERIVED = "derived"
|
||||
private const val KEY_HINT = "hint"
|
||||
|
||||
private val KNOWN_KEYS = setOf(
|
||||
KEY_TAG, KEY_MARKER, KEY_MAX, KEY_MIN, KEY_OPTIONS,
|
||||
KEY_HTTPS, KEY_REPEAT, KEY_PATTERN, KEY_DERIVED, KEY_HINT,
|
||||
)
|
||||
|
||||
/**
|
||||
* The blob read back, leniently.
|
||||
*
|
||||
* A malformed blob costs the field its constraints and not its life: the
|
||||
* NIP says a bad config must not invalidate the field, so anything that
|
||||
* is not a JSON object reads as no config at all, and a known key of the
|
||||
* wrong type is dropped rather than kept as something it is not.
|
||||
*/
|
||||
fun fromJson(json: String?): CuratedFieldConfig {
|
||||
if (json.isNullOrBlank()) return CuratedFieldConfig()
|
||||
|
||||
val obj = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull() as? JsonObject
|
||||
?: return CuratedFieldConfig()
|
||||
|
||||
fun string(key: String): String? =
|
||||
(obj[key] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
|
||||
|
||||
fun long(key: String): Long? =
|
||||
(obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull
|
||||
|
||||
fun boolean(key: String): Boolean? =
|
||||
(obj[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull
|
||||
|
||||
return CuratedFieldConfig(
|
||||
tag = string(KEY_TAG),
|
||||
marker = string(KEY_MARKER),
|
||||
max = long(KEY_MAX),
|
||||
min = long(KEY_MIN),
|
||||
options = (obj[KEY_OPTIONS] as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.contentOrNull },
|
||||
https = boolean(KEY_HTTPS),
|
||||
repeat = boolean(KEY_REPEAT),
|
||||
pattern = string(KEY_PATTERN),
|
||||
derived = boolean(KEY_DERIVED),
|
||||
hint = string(KEY_HINT),
|
||||
others = obj.filterKeys { it !in KNOWN_KEYS },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One field an entry may -- or must -- carry.
|
||||
*
|
||||
* ```
|
||||
* ["field", name, type, "required" | "optional", placeholder, label, config]
|
||||
* ```
|
||||
*
|
||||
* The four things a form needs are positional and everything else is the JSON
|
||||
* blob in the last position, which is [CuratedFieldConfig].
|
||||
*/
|
||||
data class CuratedField(
|
||||
/** Stable name; the key a form collects the value under. */
|
||||
val name: String,
|
||||
val type: CuratedFieldType,
|
||||
val isRequired: Boolean,
|
||||
/** An example value shown in an empty input. May be empty. */
|
||||
val placeholder: String = "",
|
||||
/** The human label for the input. Empty means [name]. */
|
||||
val label: String = "",
|
||||
val config: CuratedFieldConfig = CuratedFieldConfig(),
|
||||
) {
|
||||
/** The tag this field writes to; [CuratedFieldConfig.CONTENT_TAG] means the body. */
|
||||
fun tag(): String = config.tag ?: name
|
||||
|
||||
fun labelOrName(): String = label.ifBlank { name }
|
||||
|
||||
fun toTagArray(): Array<String> = arrayOf(
|
||||
TAG_NAME,
|
||||
name,
|
||||
type.value,
|
||||
if (isRequired) REQUIRED else OPTIONAL,
|
||||
placeholder,
|
||||
label,
|
||||
config.toJson(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "field"
|
||||
|
||||
private const val REQUIRED = "required"
|
||||
private const val OPTIONAL = "optional"
|
||||
|
||||
/**
|
||||
* The tag read back, or null for one that is not a field.
|
||||
*
|
||||
* Null for a blank name or an unknown type, which are the two things a
|
||||
* form cannot do anything with. Everything after them is optional: a
|
||||
* missing placeholder is empty, a missing label falls back to the name,
|
||||
* and a missing or malformed config is no config -- see
|
||||
* [CuratedFieldConfig.fromJson].
|
||||
*/
|
||||
fun parse(tag: Array<String>): CuratedField? {
|
||||
if (tag.size < 3 || tag[0] != TAG_NAME) return null
|
||||
|
||||
val name = tag[1].trim()
|
||||
if (name.isEmpty()) return null
|
||||
|
||||
val type = CuratedFieldType.parse(tag[2]) ?: return null
|
||||
|
||||
return CuratedField(
|
||||
name = name,
|
||||
type = type,
|
||||
isRequired = tag.getOrNull(3) == REQUIRED,
|
||||
placeholder = tag.getOrNull(4) ?: "",
|
||||
label = tag.getOrNull(5) ?: "",
|
||||
config = CuratedFieldConfig.fromJson(tag.getOrNull(6)),
|
||||
)
|
||||
}
|
||||
|
||||
/** The `title` field a schema gets when it left its own out. */
|
||||
val TITLE = CuratedField(
|
||||
name = "title",
|
||||
type = CuratedFieldType.Text,
|
||||
isRequired = true,
|
||||
label = "Title",
|
||||
config = CuratedFieldConfig(max = 200),
|
||||
)
|
||||
|
||||
/**
|
||||
* The `d` field a schema gets when it left its own out. Derived, so a
|
||||
* form never asks for it: the client works an identifier out of the
|
||||
* entry itself.
|
||||
*/
|
||||
val IDENTIFIER = CuratedField(
|
||||
name = "identifier",
|
||||
type = CuratedFieldType.Token,
|
||||
isRequired = true,
|
||||
label = "Identifier",
|
||||
config = CuratedFieldConfig(tag = "d", max = 80, derived = true),
|
||||
)
|
||||
|
||||
/**
|
||||
* The two fields every list has, keyed by the tag each writes to and in
|
||||
* the order [CuratedSchema.normalized] puts them back -- title first, so
|
||||
* that `d` ends up in front of it.
|
||||
*/
|
||||
val MANDATORY: List<Pair<String, CuratedField>> = listOf(
|
||||
"title" to TITLE,
|
||||
"d" to IDENTIFIER,
|
||||
)
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Kind 31889: a curated schema event, and the two kinds it governs.
|
||||
*
|
||||
* ```
|
||||
* 31889 curated schema the definition of a list, by its curator
|
||||
* 31888 curated suggestion an entry anyone proposes, in reply to the schema
|
||||
* 31890 curated canonical a suggestion the curator republished as the list's own
|
||||
* ```
|
||||
*
|
||||
* All three are addressable, so `kind:pubkey:d` names one entry and republishing
|
||||
* with the same `d` replaces it. That is what makes a schema revisable: editing
|
||||
* one signs a new event under the same coordinate, and every suggestion that
|
||||
* replied to the coordinate -- rather than to an event id -- stays attached.
|
||||
*
|
||||
* This object is the wire shape: tags in, tags out. [CuratedSchema] is what the
|
||||
* tags mean, and `GroupCuratedSchema` is a group's own signed one.
|
||||
*/
|
||||
object CuratedSchemaEvent {
|
||||
const val KIND: Kind = 31889
|
||||
|
||||
/** The kind a schema's suggestions are published under -- its `k` tag. */
|
||||
const val SUGGESTION_KIND: Kind = 31888
|
||||
|
||||
/** The kind the curator republishes an accepted suggestion as. */
|
||||
const val CANONICAL_KIND: Kind = 31890
|
||||
|
||||
/** Length caps on the identity tags, the reference module's. */
|
||||
const val MAX_IDENTIFIER = 100
|
||||
const val MAX_TITLE = 200
|
||||
const val MAX_NAME = 100
|
||||
const val MAX_DESCRIPTION = 500
|
||||
const val MAX_URL = 500
|
||||
const val MAX_DOMAIN = 253
|
||||
|
||||
private const val TAG_IDENTIFIER = "d"
|
||||
private const val TAG_TITLE = "title"
|
||||
private const val TAG_NAME = "name"
|
||||
private const val TAG_DESCRIPTION = "description"
|
||||
private const val TAG_KIND = "k"
|
||||
private const val TAG_VISIBILITY = "visibility"
|
||||
private const val TAG_PICTURE = "picture"
|
||||
private const val TAG_DOMAIN = "domain"
|
||||
private const val TAG_REQUIRE_ANY = "require-any"
|
||||
private const val TAG_SUGGESTER = "p"
|
||||
private const val TAG_RELAY = "relay"
|
||||
|
||||
/** The addressable coordinate: what a suggestion's `a` root names. */
|
||||
fun coordinate(pubkey: HexKey, identifier: String): String = "$KIND:$pubkey:$identifier"
|
||||
|
||||
/**
|
||||
* The schema an event declares, or null when the event is not a usable one.
|
||||
*
|
||||
* Defensive in the way a relay demands. The wrong kind is null. Any of
|
||||
* [CuratedSchema.problems] is null -- a schema that would be rejected on
|
||||
* publish is rejected on read, and for the same reasons. A `field` tag that
|
||||
* will not parse is dropped and the rest are kept, which is the one place
|
||||
* the NIP asks for tolerance; a `require-any` naming fewer than two fields
|
||||
* says nothing and goes the same way.
|
||||
*
|
||||
* What comes back is [CuratedSchema.normalized]: the two mandatory fields
|
||||
* forced in, whatever the event said.
|
||||
*/
|
||||
fun parse(event: Event): CuratedSchema? {
|
||||
if (event.kind != KIND) return null
|
||||
|
||||
return read(event.tags, event.content).takeIf { it.isUsable() }?.normalized()
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags as a schema, whether or not they make a usable one.
|
||||
*
|
||||
* Split from [parse] so that a reader wanting the reasons -- the editor,
|
||||
* a test -- can ask [CuratedSchema.problems] of the result. The identity
|
||||
* values are trimmed, and the description falls back to the content the way
|
||||
* generic clients read it: the tag is authoritative and the content is the
|
||||
* mirror.
|
||||
*/
|
||||
fun read(tags: Array<Array<String>>, content: String): CuratedSchema {
|
||||
fun first(name: String): String? =
|
||||
tags.firstOrNull { it.size > 1 && it[0] == name }?.get(1)
|
||||
|
||||
fun all(name: String): List<String> =
|
||||
tags.filter { it.size > 1 && it[0] == name }.map { it[1] }
|
||||
|
||||
return CuratedSchema(
|
||||
identifier = first(TAG_IDENTIFIER)?.trim() ?: "",
|
||||
title = first(TAG_TITLE)?.trim() ?: "",
|
||||
name = first(TAG_NAME)?.trim() ?: "",
|
||||
description = (first(TAG_DESCRIPTION)?.trim()?.ifEmpty { null } ?: content).trim(),
|
||||
// Null for an absent or unrecognised word, and null stays null: see
|
||||
// the field's own note on why it is never defaulted.
|
||||
visibility = first(TAG_VISIBILITY)?.let(CuratedVisibility::parse),
|
||||
picture = first(TAG_PICTURE)?.trim()?.ifEmpty { null },
|
||||
domain = first(TAG_DOMAIN)?.trim()?.ifEmpty { null }?.let(::normalizeDomain),
|
||||
suggestionKind = first(TAG_KIND)?.trim()?.toIntOrNull() ?: SUGGESTION_KIND,
|
||||
fields = tags.mapNotNull(CuratedField::parse),
|
||||
requireAny = tags
|
||||
.filter { it.isNotEmpty() && it[0] == TAG_REQUIRE_ANY }
|
||||
.map { group -> group.drop(1).map { it.trim() }.filter { it.isNotEmpty() } }
|
||||
.filter { it.size > 1 },
|
||||
// Only what is a pubkey. A `p` tag holding anything else names nobody,
|
||||
// and keeping it would have the editor render it as an npub it is not.
|
||||
suggesters = all(TAG_SUGGESTER).map { it.trim().lowercase() }.filter(::isPubkey).distinct(),
|
||||
relays = all(TAG_RELAY).map { it.trim() }.distinct(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The unsigned event that publishes [schema], for the group to sign.
|
||||
*
|
||||
* Built from the schema alone rather than from the group's last event, the
|
||||
* way a relay list is and for the same reason: a schema is one document
|
||||
* whose whole point in being re-signed is to replace the last one, and
|
||||
* carrying an old tag forward would make removing a field impossible.
|
||||
*
|
||||
* The content mirrors the description so that a client reading content
|
||||
* rather than tags still shows something; the tag is the authoritative one.
|
||||
*/
|
||||
fun template(
|
||||
schema: CuratedSchema,
|
||||
createdAt: Long = Clock.System.now().epochSeconds,
|
||||
): EventTemplate<Nothing> {
|
||||
val normalized = schema.normalized()
|
||||
|
||||
val tags = buildList {
|
||||
add(arrayOf(TAG_IDENTIFIER, normalized.identifier))
|
||||
add(arrayOf(TAG_TITLE, normalized.title))
|
||||
add(arrayOf(TAG_NAME, normalized.name))
|
||||
add(arrayOf(TAG_DESCRIPTION, normalized.description))
|
||||
add(arrayOf(TAG_KIND, normalized.suggestionKind.toString()))
|
||||
// Absent rather than invented when the schema has none. Every reader,
|
||||
// this one included, then refuses the event -- which is the right
|
||||
// outcome for a schema that does not say who may suggest to it, and
|
||||
// one the editor never produces.
|
||||
normalized.visibility?.let { add(arrayOf(TAG_VISIBILITY, it.value)) }
|
||||
|
||||
normalized.picture?.let { add(arrayOf(TAG_PICTURE, it)) }
|
||||
normalized.domain?.let { add(arrayOf(TAG_DOMAIN, normalizeDomain(it))) }
|
||||
|
||||
normalized.fields.forEach { add(it.toTagArray()) }
|
||||
normalized.requireAny.forEach { group -> add(arrayOf(TAG_REQUIRE_ANY, *group.toTypedArray())) }
|
||||
normalized.suggesters.forEach { add(arrayOf(TAG_SUGGESTER, it)) }
|
||||
normalized.relays.forEach { add(arrayOf(TAG_RELAY, it)) }
|
||||
}
|
||||
|
||||
return EventTemplate(
|
||||
createdAt = createdAt,
|
||||
kind = KIND,
|
||||
tags = tags.toTypedArray(),
|
||||
content = normalized.description,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A hostname and nothing else: at least two labels, each of letters, digits
|
||||
* and inner hyphens, no scheme, port or path, 253 characters at most. What
|
||||
* [normalizeDomain] leaves of a pasted URL is checked against this, so
|
||||
* `https://Bitcoin.MOV/` is accepted and stored as `bitcoin.mov`.
|
||||
*/
|
||||
private val DOMAIN = Regex(
|
||||
"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$"
|
||||
)
|
||||
|
||||
/** Trim the things people paste around a bare hostname. */
|
||||
fun normalizeDomain(value: String): String = value
|
||||
.trim()
|
||||
.lowercase()
|
||||
.replace(Regex("^[a-z][a-z0-9+.-]*://"), "")
|
||||
.removePrefix("@")
|
||||
.replace(Regex("/.*$"), "")
|
||||
.replace(Regex(":\\d+$"), "")
|
||||
.removeSuffix(".")
|
||||
|
||||
/** 64 lowercase hex characters: an x-only pubkey as nostr writes one. */
|
||||
fun isPubkey(value: String): Boolean =
|
||||
value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
|
||||
|
||||
fun isDomain(value: String): Boolean =
|
||||
normalizeDomain(value).let { it.length <= MAX_DOMAIN && DOMAIN.matches(it) }
|
||||
|
||||
/**
|
||||
* An https URL with a host: the only kind of picture a schema may carry,
|
||||
* because it gets rendered and browsers block the rest on a secure page.
|
||||
*/
|
||||
fun isHttpsUrl(value: String): Boolean = HTTPS_URL.matches(value.trim())
|
||||
|
||||
private val HTTPS_URL = Regex("^https://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* A `ws://` or `wss://` URL with a host, which is what the NIP accepts in a
|
||||
* `relay` tag. The editor is stricter about what it will let a group sign --
|
||||
* see `GroupRelaySet.relayUrlOrNull` -- but a schema read off a relay is held
|
||||
* to the NIP's rule and not to this app's.
|
||||
*/
|
||||
fun isRelayUrl(value: String): Boolean = RELAY_URL.matches(value.trim())
|
||||
|
||||
private val RELAY_URL = Regex("^wss?://[^\\s/?#]+[^\\s]*$", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.managers.MarmotGroupCreation
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.database.model.Participant
|
||||
@@ -113,6 +114,17 @@ interface ChatRepository {
|
||||
*/
|
||||
suspend fun groupPosts(chatRoomId: String): List<GroupPost>
|
||||
|
||||
/**
|
||||
* The curated schemas this group signed -- the lists it curates -- one per
|
||||
* identifier, most recently signed first.
|
||||
*
|
||||
* Kind 31889 is addressable, so an edit is a newer event under the same
|
||||
* identifier and the newest wins per list, the way the profile does; two
|
||||
* identifiers are two lists and both come back. Empty in a group that
|
||||
* curates nothing, which is every group until somebody proposes one.
|
||||
*/
|
||||
suspend fun groupCuratedSchemas(chatRoomId: String): List<GroupCuratedSchema>
|
||||
|
||||
/**
|
||||
* Whether this device holds a share of the group's key, and so could take
|
||||
* part in signing for it.
|
||||
@@ -301,6 +313,10 @@ interface ChatRepository {
|
||||
|
||||
override suspend fun groupPosts(chatRoomId: String): List<GroupPost> = emptyList()
|
||||
|
||||
override suspend fun groupCuratedSchemas(
|
||||
chatRoomId: String
|
||||
): List<GroupCuratedSchema> = emptyList()
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = false
|
||||
|
||||
override suspend fun refuseSubgroup(
|
||||
|
||||
@@ -2,6 +2,7 @@ package press.mantra.compose.text
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
import press.mantra.compose.nostr.nip30303.ArtifactEvent
|
||||
@@ -155,6 +156,22 @@ object ProposedEvent {
|
||||
).joinToString(" · ")
|
||||
)
|
||||
|
||||
// A list the group would curate. A member signing this is agreeing to
|
||||
// take suggestions from whoever the visibility admits and to be the one
|
||||
// key that accepts them, so the summary is the list's name, who may
|
||||
// suggest, and how much an entry asks for -- the content is only the
|
||||
// description, which the detail screen shows whole.
|
||||
CuratedSchemaEvent.KIND -> Summary(
|
||||
label = "Curated schema",
|
||||
detail = CuratedSchemaEvent.read(event.tags, event.content).let { schema ->
|
||||
listOfNotNull(
|
||||
schema.displayName().ifBlank { null },
|
||||
schema.visibility?.value,
|
||||
schema.fields.size.let { "$it ${if (it == 1) "field" else "fields"}" }
|
||||
).joinToString(" · ")
|
||||
}
|
||||
)
|
||||
|
||||
else -> Summary(label = "Event of kind ${event.kind}", detail = event.content)
|
||||
}
|
||||
}
|
||||
+179
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Badge
|
||||
import androidx.compose.material.icons.filled.Ballot
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
@@ -74,6 +77,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
@@ -100,6 +104,10 @@ import mantra.composeapp.generated.resources.relays
|
||||
import mantra.composeapp.generated.resources.posts
|
||||
import mantra.composeapp.generated.resources.add_post
|
||||
import mantra.composeapp.generated.resources.a_reply
|
||||
import mantra.composeapp.generated.resources.curated_schemas
|
||||
import mantra.composeapp.generated.resources.this_group_has_not_published_a_curated_schema_yet
|
||||
import mantra.composeapp.generated.resources.add_schema
|
||||
import mantra.composeapp.generated.resources.fields_colon
|
||||
import mantra.composeapp.generated.resources.posted_on
|
||||
import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet
|
||||
import mantra.composeapp.generated.resources.edit_relays
|
||||
@@ -124,6 +132,7 @@ import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdmins
|
||||
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
@@ -521,6 +530,90 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Under the posts, because a schema is the other
|
||||
// thing a group publishes for strangers to answer:
|
||||
// a post is the group speaking, a schema is the
|
||||
// group asking -- for entries to a list it will
|
||||
// then curate, by quorum, under the same key. It
|
||||
// closes the identity block because everything in
|
||||
// that block is signed by that key and read by
|
||||
// people who were never in the room.
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.curated_schemas),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (chatRoomDetailUIState.groupCuratedSchemas.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.this_group_has_not_published_a_curated_schema_yet
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = chatRoomDetailUIState.groupCuratedSchemas,
|
||||
key = { curated -> curated.signedEvent.id }
|
||||
) { curated ->
|
||||
GroupCuratedSchemaCard(
|
||||
curatedSchema = curated,
|
||||
// Into the editor for a member who
|
||||
// could propose a revision, and a plain
|
||||
// card for one who could not: the same
|
||||
// gate as every button in this block,
|
||||
// applied to the card because each
|
||||
// schema is edited on its own.
|
||||
onEdit = if (chatRoomDetailUIState.canEditNostrProfile) {
|
||||
{
|
||||
onNavigateToRoute.invoke(
|
||||
EditGroupCuratedSchemaRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
identifier = curated.identifier
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (chatRoomDetailUIState.canEditNostrProfile) {
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
EditGroupCuratedSchemaRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
identifier = null
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.PlaylistAdd,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(
|
||||
MaterialTheme.spacing.space125
|
||||
)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.add_schema))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
@@ -1469,6 +1562,92 @@ private fun GroupPostCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One list the group curates, as the schema it signed for it.
|
||||
*
|
||||
* Named by the list -- the domain where it claims one, else the name -- because
|
||||
* that is what a suggester sees the list called; the title under it is the label
|
||||
* the event carries. The fields are listed by label rather than counted, since
|
||||
* what a reader wants to know about a list is what an entry in it asks for, and
|
||||
* "13 fields" answers a different question.
|
||||
*
|
||||
* The visibility is on the last line beside the date, because who may suggest is
|
||||
* the one fact on the card a member might act on -- a closed list is one they
|
||||
* would have to be named on.
|
||||
*
|
||||
* [onEdit] null draws a card that cannot be opened. It is the section's gate
|
||||
* applied per card: a schema is edited on its own, so the way in has to be on it.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupCuratedSchemaCard(
|
||||
curatedSchema: GroupCuratedSchema,
|
||||
onEdit: (() -> Unit)?
|
||||
) {
|
||||
val content: @Composable () -> Unit = {
|
||||
ListItem(
|
||||
leadingContent = {
|
||||
Icon(Icons.Default.Ballot, contentDescription = Decorative)
|
||||
},
|
||||
trailingContent = onEdit?.let {
|
||||
{ Icon(Icons.Default.Edit, contentDescription = "Edit schema") }
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = curatedSchema.schema.displayName(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
|
||||
) {
|
||||
Text(
|
||||
text = curatedSchema.schema.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = curatedSchema.schema.description,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.fields_colon,
|
||||
curatedSchema.schema.fields.joinToString { it.labelOrName() }
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
curatedSchema.schema.visibility?.let { stringResource(it.label()) },
|
||||
stringResource(
|
||||
Res.string.signed_by_the_group_on,
|
||||
curatedSchema.signedAt.toFormattedTimeAndDateString()
|
||||
)
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (onEdit != null) {
|
||||
Card(modifier = Modifier.fillMaxWidth(), onClick = onEdit) { content() }
|
||||
} else {
|
||||
Card(modifier = Modifier.fillMaxWidth()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One subgroup, as the parent's record and this device's rooms together have it.
|
||||
*
|
||||
|
||||
+1521
File diff suppressed because it is too large.
Load diff
+23
@@ -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.EditGroupCuratedSchemaScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupRelaysScreen
|
||||
import press.mantra.compose.ui.composable.HomeScreen
|
||||
import press.mantra.compose.ui.composable.ImplementationPendingScreen
|
||||
@@ -127,6 +128,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.EditGroupCuratedSchemaRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
@@ -1034,6 +1036,27 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<EditGroupCuratedSchemaRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<EditGroupCuratedSchemaRoute>()
|
||||
|
||||
EditGroupCuratedSchemaScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
identifier = route.identifier,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace the editor so back returns to the group rather
|
||||
// than to a schema whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<EditGroupCuratedSchemaRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<FrostSigningRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<FrostSigningRoute>()
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The editor for one of a group's curated schemas.
|
||||
*
|
||||
* [identifier] is the schema's `d`, and null opens the editor on a new one. It
|
||||
* is the identifier rather than the event id because kind 31889 is addressable:
|
||||
* an edit replaces the event under the same `d`, and the editor's job is to
|
||||
* propose that replacement, so the coordinate is what names the thing being
|
||||
* edited and the event is only its current version.
|
||||
*/
|
||||
@Serializable
|
||||
data class EditGroupCuratedSchemaRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String, // TODO: have this as a publicKey
|
||||
val relayHint: String?,
|
||||
val identifier: String?
|
||||
): Route()
|
||||
+1
@@ -91,6 +91,7 @@ class ChatRoomDetailViewModel(
|
||||
groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
|
||||
groupRelayLists = chatRepository.groupRelayLists(chatRoomId),
|
||||
groupPosts = chatRepository.groupPosts(chatRoomId),
|
||||
groupCuratedSchemas = chatRepository.groupCuratedSchemas(chatRoomId),
|
||||
canEditNostrProfile = canSign,
|
||||
canAddSubgroup = canSign,
|
||||
)
|
||||
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.extensions.bech32ToHexOrNull
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import press.mantra.compose.nostr.curated.CuratedField
|
||||
import press.mantra.compose.nostr.curated.CuratedFieldType
|
||||
import press.mantra.compose.nostr.curated.CuratedSchema
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaProblem
|
||||
import press.mantra.compose.nostr.curated.CuratedVisibility
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.ui.view.state.EditGroupCuratedSchemaUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* One of the group's curated schemas, edited locally and proposed as one event.
|
||||
*
|
||||
* Nothing here is saved. The parts of the schema that are lists -- its fields,
|
||||
* its rules, its suggesters, its relays -- live on this view model as snapshot
|
||||
* state so that a bottom sheet opening over the form, or the window turning,
|
||||
* does not throw an edit away; the identity fields are text and live in the
|
||||
* composition like every other form's. The button folds both halves into a
|
||||
* [CuratedSchema], refuses it for any reason a reader would, and otherwise opens
|
||||
* one signing session over it. The schema changes on every member's device at
|
||||
* once, when a quorum has signed, or not at all.
|
||||
*
|
||||
* ### Edit and replace
|
||||
*
|
||||
* Kind 31889 is addressable, so proposing a schema under an identifier the group
|
||||
* already signed one for *replaces* it -- which is what an edit is, and why the
|
||||
* identifier is the one thing the editor will not let an edit change. A new
|
||||
* identifier is a new list, with a queue of its own, and the group's screen
|
||||
* offers that as adding a schema rather than as editing this one.
|
||||
*
|
||||
* The comparison for "nothing changed" is against the schema as the group signed
|
||||
* it, normalized on both sides, because re-signing an identical schema would put
|
||||
* a second statement on the record with a newer timestamp: harmless, since the
|
||||
* kind is replaceable, and still a lie about when the group last decided
|
||||
* anything about the list.
|
||||
*/
|
||||
class EditGroupCuratedSchemaViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
/** The `d` of the schema being edited, or null for a new one. */
|
||||
val identifier: String?,
|
||||
initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var editGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState by mutableStateOf(
|
||||
initialEditGroupCuratedSchemaUIState
|
||||
)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
/** Who may suggest. Always set: the editor never proposes a schema without one. */
|
||||
var visibility: CuratedVisibility by mutableStateOf(CuratedVisibility.Public)
|
||||
private set
|
||||
|
||||
/** The working copy of the schema's fields, in the order they will be written. */
|
||||
val fields = mutableStateListOf<CuratedField>()
|
||||
|
||||
/**
|
||||
* The working copy of the `require-any` rules, each a set of field names.
|
||||
*
|
||||
* A rule may hold fewer than two names while it is being built; one that
|
||||
* still does when the button is pressed says nothing and is dropped rather
|
||||
* than refused -- see `CuratedSchema.requireAny`.
|
||||
*/
|
||||
val requireAny = mutableStateListOf<List<String>>()
|
||||
|
||||
/** The working copy of the `p` tags: pubkeys besides the group that may suggest. */
|
||||
val suggesters = mutableStateListOf<HexKey>()
|
||||
|
||||
/** The working copy of the `relay` tags. */
|
||||
val relays = mutableStateListOf<String>()
|
||||
|
||||
/** The field the sheet is open on, or null while it is closed. */
|
||||
var editingField: FieldEdit? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* 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<CuratedSchemaProblem> by mutableStateOf(emptyList())
|
||||
private set
|
||||
|
||||
/** Why the last relay was refused, or null. Cleared by the next keystroke. */
|
||||
var addRelayFailure: AddRelayFailure? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Why the last suggester was refused, or null. Cleared by the next keystroke. */
|
||||
var addSuggesterFailure: AddSuggesterFailure? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var isSeeded = false
|
||||
|
||||
/**
|
||||
* A field on the sheet. [index] is its place in [fields], or null for one
|
||||
* being added -- which is also what decides whether committing it appends
|
||||
* or replaces.
|
||||
*/
|
||||
data class FieldEdit(val index: Int?, val field: CuratedField)
|
||||
|
||||
enum class AddRelayFailure {
|
||||
/** Not a `wss://` URL, or one pointing at this machine. */
|
||||
NotARelay,
|
||||
|
||||
/** Already named, which is a no-op rather than a mistake. */
|
||||
AlreadyListed,
|
||||
}
|
||||
|
||||
enum class AddSuggesterFailure {
|
||||
/** Neither an npub nor 64 hex characters. */
|
||||
NotAPubkey,
|
||||
|
||||
AlreadyListed,
|
||||
}
|
||||
|
||||
/** Why a field cannot be committed as it stands. */
|
||||
enum class FieldProblem {
|
||||
NameMissing,
|
||||
|
||||
/** A field name is the key a form collects a value under, and a key is one word. */
|
||||
NameHasSpaces,
|
||||
|
||||
/** Another field already answers to that name. */
|
||||
NameTaken,
|
||||
}
|
||||
|
||||
fun initiateEditGroupCuratedSchema() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
editGroupCuratedSchemaUIState = if (localChatRoom == null) {
|
||||
EditGroupCuratedSchemaUIState.Error("Couldn't find the chat room")
|
||||
} else {
|
||||
val existing = identifier?.let { wanted ->
|
||||
chatRepository.groupCuratedSchemas(chatRoomId).firstOrNull { it.identifier == wanted }
|
||||
}
|
||||
|
||||
if (identifier != null && existing == null) {
|
||||
// Opened on a schema this device does not hold. Nothing to
|
||||
// retry and nothing to edit; offering a blank form under that
|
||||
// identifier would propose a replacement for a list nobody
|
||||
// here has read.
|
||||
EditGroupCuratedSchemaUIState.Error("Couldn't find that schema")
|
||||
} else {
|
||||
EditGroupCuratedSchemaUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
existing = existing,
|
||||
defaultRelays = defaultRelays(chatRoomId),
|
||||
canSign = localChatRoom.chatRoom.mlsGroupState != null &&
|
||||
frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new schema says its list lives: the relays the group has said it
|
||||
* writes to, since that is where its canonical entries would be read from.
|
||||
*
|
||||
* The NIP-65 list where the group has agreed one, else that set's defaults
|
||||
* -- which is this build's own relay, and the same thing a group opening the
|
||||
* relay editor for the first time is shown. A schema with no relays is one
|
||||
* whose suggestions could go anywhere, so the seed is worth getting right.
|
||||
*/
|
||||
private suspend fun defaultRelays(chatRoomId: String): List<String> {
|
||||
val general = chatRepository.groupRelayLists(chatRoomId)
|
||||
.firstOrNull { it.set == GroupRelaySet.General }
|
||||
?: return GroupRelaySet.General.defaults().map { it.url.url }
|
||||
|
||||
return (if (general.isAgreed) general.relays else general.set.defaults())
|
||||
.filter { it.write }
|
||||
.map { it.url.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the working copy from the schema the group signed, once.
|
||||
*
|
||||
* An existing schema starts at itself. A new one starts at the two fields
|
||||
* every list has -- the identifier and the title, which `CuratedSchema.normalized`
|
||||
* would put back anyway, shown up front so that what the member sees is what
|
||||
* the group will sign -- and at the group's own relays.
|
||||
*
|
||||
* Called from the screen rather than from [initiateEditGroupCuratedSchema],
|
||||
* 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: EditGroupCuratedSchemaUIState.Loaded) {
|
||||
if (isSeeded) return
|
||||
isSeeded = true
|
||||
|
||||
val schema = state.existing?.schema
|
||||
if (schema != null) {
|
||||
visibility = schema.visibility ?: CuratedVisibility.Public
|
||||
fields.addAll(schema.fields)
|
||||
requireAny.addAll(schema.requireAny)
|
||||
suggesters.addAll(schema.suggesters)
|
||||
relays.addAll(schema.relays)
|
||||
} else {
|
||||
visibility = CuratedVisibility.Public
|
||||
fields.addAll(listOf(CuratedField.IDENTIFIER, CuratedField.TITLE))
|
||||
relays.addAll(state.defaultRelays)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectVisibility(visibility: CuratedVisibility) {
|
||||
this.visibility = visibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [field] is one of the two every list must have.
|
||||
*
|
||||
* Decided by the tag it writes to, not its name, because that is what the
|
||||
* NIP's rule is about: a list needs a field writing to `d` and one writing
|
||||
* to `title`, whatever they are called. Such a field cannot be removed or
|
||||
* made optional here -- `CuratedSchema.normalized` would only put it back.
|
||||
*/
|
||||
fun isMandatory(field: CuratedField): Boolean =
|
||||
CuratedField.MANDATORY.any { (tag, _) -> field.tag() == tag }
|
||||
|
||||
/** Opens the sheet on a blank field. Text, because most fields are. */
|
||||
fun startAddingField() {
|
||||
editingField = FieldEdit(
|
||||
index = null,
|
||||
field = CuratedField(name = "", type = CuratedFieldType.Text, isRequired = false),
|
||||
)
|
||||
}
|
||||
|
||||
fun startEditingField(index: Int) {
|
||||
fields.getOrNull(index)?.let { editingField = FieldEdit(index = index, field = it) }
|
||||
}
|
||||
|
||||
fun dismissFieldEditor() {
|
||||
editingField = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts [field] into the working copy at the place the sheet was opened on,
|
||||
* or says why it cannot.
|
||||
*
|
||||
* Null means it went in and the sheet is closed. A rename follows through to
|
||||
* the rules that named the field, since a rule naming a field that no longer
|
||||
* exists would silently never be satisfied.
|
||||
*/
|
||||
fun commitField(field: CuratedField): FieldProblem? {
|
||||
val edit = editingField ?: return null
|
||||
|
||||
val name = field.name.trim()
|
||||
if (name.isEmpty()) return FieldProblem.NameMissing
|
||||
if (name.any { it.isWhitespace() }) return FieldProblem.NameHasSpaces
|
||||
if (fields.withIndex().any { (index, other) -> index != edit.index && other.name == name }) {
|
||||
return FieldProblem.NameTaken
|
||||
}
|
||||
|
||||
val committed = field.copy(name = name)
|
||||
|
||||
if (edit.index == null) {
|
||||
fields.add(committed)
|
||||
} else {
|
||||
val previous = fields[edit.index]
|
||||
fields[edit.index] = committed
|
||||
if (previous.name != name) {
|
||||
requireAny.indices.forEach { group ->
|
||||
requireAny[group] = requireAny[group].map { if (it == previous.name) name else it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editingField = null
|
||||
return null
|
||||
}
|
||||
|
||||
/** Drops the field at [index] and takes it out of any rule that named it. */
|
||||
fun removeField(index: Int) {
|
||||
val removed = fields.getOrNull(index) ?: return
|
||||
if (isMandatory(removed)) return
|
||||
|
||||
fields.removeAt(index)
|
||||
requireAny.indices.forEach { group ->
|
||||
requireAny[group] = requireAny[group].filterNot { it == removed.name }
|
||||
}
|
||||
editingField = null
|
||||
}
|
||||
|
||||
fun addRequireAnyGroup() {
|
||||
requireAny.add(emptyList())
|
||||
}
|
||||
|
||||
fun toggleRequireAny(group: Int, fieldName: String) {
|
||||
val names = requireAny.getOrNull(group) ?: return
|
||||
requireAny[group] = if (fieldName in names) names - fieldName else names + fieldName
|
||||
}
|
||||
|
||||
fun removeRequireAnyGroup(group: Int) {
|
||||
if (group in requireAny.indices) requireAny.removeAt(group)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [input] to the suggesters, or says why it did not.
|
||||
*
|
||||
* True when the row appeared, which is the caller's cue to clear the field.
|
||||
*/
|
||||
fun addSuggester(input: String): Boolean {
|
||||
val pubkey = pubkeyOrNull(input) ?: run {
|
||||
addSuggesterFailure = AddSuggesterFailure.NotAPubkey
|
||||
return false
|
||||
}
|
||||
|
||||
if (pubkey in suggesters) {
|
||||
addSuggesterFailure = AddSuggesterFailure.AlreadyListed
|
||||
return false
|
||||
}
|
||||
|
||||
suggesters.add(pubkey)
|
||||
addSuggesterFailure = null
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeSuggester(pubkey: HexKey) {
|
||||
suggesters.remove(pubkey)
|
||||
}
|
||||
|
||||
fun clearAddSuggesterFailure() {
|
||||
addSuggesterFailure = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [url] to the relays, or says why it did not.
|
||||
*
|
||||
* Held to the same rule as the group's relay lists -- `wss://`, not this
|
||||
* machine -- rather than to the NIP's looser one, because this is a relay
|
||||
* the group is about to sign for strangers to publish to. See
|
||||
* `GroupRelaySet.relayUrlOrNull`.
|
||||
*/
|
||||
fun addRelay(url: String): Boolean {
|
||||
val relay = GroupRelaySet.relayUrlOrNull(url) ?: run {
|
||||
addRelayFailure = AddRelayFailure.NotARelay
|
||||
return false
|
||||
}
|
||||
|
||||
// Compared normalized, because a relay signed into an existing schema is
|
||||
// kept as it was written and the normalizer adds a trailing slash: the
|
||||
// same host with and without one is one relay, not two.
|
||||
if (relays.any { GroupRelaySet.relayUrlOrNull(it) == relay }) {
|
||||
addRelayFailure = AddRelayFailure.AlreadyListed
|
||||
return false
|
||||
}
|
||||
|
||||
relays.add(relay.url)
|
||||
addRelayFailure = null
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeRelay(url: String) {
|
||||
relays.remove(url)
|
||||
}
|
||||
|
||||
fun clearAddRelayFailure() {
|
||||
addRelayFailure = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema as the form has it: the text fields trimmed and the lists as
|
||||
* they stand, with a rule too short to mean anything left out.
|
||||
*/
|
||||
fun draft(
|
||||
identifier: String,
|
||||
title: String,
|
||||
name: String,
|
||||
description: String,
|
||||
picture: String,
|
||||
domain: String,
|
||||
): CuratedSchema = CuratedSchema(
|
||||
identifier = identifier.trim(),
|
||||
title = title.trim(),
|
||||
name = name.trim(),
|
||||
description = description.trim(),
|
||||
visibility = visibility,
|
||||
picture = picture.trim().ifEmpty { null },
|
||||
domain = domain.trim().ifEmpty { null }?.let(CuratedSchemaEvent::normalizeDomain),
|
||||
fields = fields.toList(),
|
||||
requireAny = requireAny.filter { it.size > 1 },
|
||||
suggesters = suggesters.toList(),
|
||||
relays = relays.toList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Opens a session over the schema, or says why not.
|
||||
*
|
||||
* Three refusals before anything is proposed and each is the caller's to
|
||||
* say: a schema a reader would reject, which is [onInvalid] with the
|
||||
* reasons; an edit that changed nothing, which is [onNothingToPropose]; and
|
||||
* a proposal that could not be opened, which is [onFailure]. All of them
|
||||
* stay on the form with the edits still in it.
|
||||
*/
|
||||
fun proposeSchema(
|
||||
localChatRoom: LocalChatRoom,
|
||||
existing: GroupCuratedSchema?,
|
||||
identifier: String,
|
||||
title: String,
|
||||
name: String,
|
||||
description: String,
|
||||
picture: String,
|
||||
domain: String,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onInvalid: () -> Unit,
|
||||
onNothingToPropose: () -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
// Guard against double submits. Two sessions over two versions of one
|
||||
// schema would leave the list's form decided by whichever quorum
|
||||
// finished last.
|
||||
if (isActionPending.value) return
|
||||
|
||||
val schema = draft(
|
||||
// The identifier of an existing schema is the coordinate every
|
||||
// suggestion to it replies to, so an edit keeps it whatever the
|
||||
// field says -- and the field is read-only to say so.
|
||||
identifier = existing?.identifier ?: identifier,
|
||||
title = title,
|
||||
name = name,
|
||||
description = description,
|
||||
picture = picture,
|
||||
domain = domain,
|
||||
)
|
||||
|
||||
problems = schema.problems()
|
||||
if (problems.isNotEmpty()) {
|
||||
onInvalid.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
if (existing != null && existing.schema == schema.normalized()) {
|
||||
onNothingToPropose.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
val template = GroupCuratedSchema.template(schema)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a curated schema for $chatRoomId", 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 = "EditGroupCuratedSchemaViewModel"
|
||||
|
||||
/**
|
||||
* [input] as a pubkey the schema could name, or null.
|
||||
*
|
||||
* An npub -- with or without a `nostr:` in front, the way it is pasted
|
||||
* from most clients -- or 64 hex characters in either case. Nothing else,
|
||||
* and in particular not a NIP-05 address, since resolving one is a
|
||||
* network round trip and this is a field on a form.
|
||||
*/
|
||||
fun pubkeyOrNull(input: String): HexKey? {
|
||||
val trimmed = input.trim().removePrefix("nostr:")
|
||||
|
||||
val hex = if (trimmed.startsWith("npub1", ignoreCase = true)) {
|
||||
trimmed.bech32ToHexOrNull()
|
||||
} else {
|
||||
trimmed.lowercase()
|
||||
}
|
||||
|
||||
return hex?.takeIf { candidate ->
|
||||
candidate.length == 64 && candidate.all { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
}
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
identifier: String?,
|
||||
initialEditGroupCuratedSchemaUIState: EditGroupCuratedSchemaUIState =
|
||||
EditGroupCuratedSchemaUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
EditGroupCuratedSchemaViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
identifier = identifier,
|
||||
initialEditGroupCuratedSchemaUIState = initialEditGroupCuratedSchemaUIState,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -7,6 +7,7 @@ import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
@@ -84,6 +85,17 @@ sealed interface ChatRoomDetailUIState {
|
||||
* nothing, which is every group until somebody proposes one.
|
||||
*/
|
||||
val groupPosts: List<GroupPost> = emptyList(),
|
||||
/**
|
||||
* The lists this group curates, as the schemas it signed for them, most
|
||||
* recently signed first.
|
||||
*
|
||||
* Under the posts because a schema is the other thing a group publishes
|
||||
* for strangers to answer: a post is the group speaking, a schema is the
|
||||
* group asking -- for entries to a list it will then curate. Empty in a
|
||||
* group that curates nothing, which is every group until somebody
|
||||
* proposes a schema.
|
||||
*/
|
||||
val groupCuratedSchemas: List<GroupCuratedSchema> = emptyList(),
|
||||
/**
|
||||
* Whether this device could sign a profile for the group.
|
||||
*
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupCuratedSchema
|
||||
|
||||
sealed interface EditGroupCuratedSchemaUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
/**
|
||||
* The schema being edited, as the group last signed it, or null for a new
|
||||
* one.
|
||||
*
|
||||
* Kept beside the working copy rather than replaced by it, because the
|
||||
* button proposes only when something differs -- and "differs" needs
|
||||
* both halves. Null also locks nothing: a new schema's identifier is the
|
||||
* member's to choose, and an existing one's is not.
|
||||
*/
|
||||
val existing: GroupCuratedSchema? = null,
|
||||
/**
|
||||
* The relays a new schema starts with: where the group has said it
|
||||
* publishes, since that is where its list would be read.
|
||||
*
|
||||
* Only a new schema is seeded from these. An existing one has its own
|
||||
* relays signed into it, and those are the ones an edit starts from.
|
||||
*/
|
||||
val defaultRelays: List<String> = emptyList(),
|
||||
/**
|
||||
* Whether this device holds a share of the group's key.
|
||||
*
|
||||
* False leaves the form writable and the button inert, for the same
|
||||
* reason the other editors do: drafting a schema costs nothing, and only
|
||||
* a share-holder can open the session that would sign it.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): EditGroupCuratedSchemaUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): EditGroupCuratedSchemaUIState
|
||||
|
||||
data object Loading: EditGroupCuratedSchemaUIState
|
||||
}
|
||||
Reference in new issue
Block a user