feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says
The group's identity block has a typed editor for each thing the group signs about itself -- the profile form, the post composer, the relay editor, the schema editor -- and each builds one kind of event. This is the untyped way in: an unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to the quorum like anything else, and filed by kind once it is signed. A pasted kind 0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind 10002 the general relay list. The entry is "Propose event", last in the identity block, behind the same share-holder gate as every button in it and absent from a NIP-17 room like the block itself. The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov repo prints exactly the kind 31889 event it would publish, id, pubkey, signature and all; until now the only way to get that list under a group's key was to retype it field by field into the editor. Now it is pasted, checked, and signed. **Where the event lands is decided by nothing in this change, and that is the design.** `FrostSigningManager.complete` files every signed event as a `GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists and schemas off those rows by kind, each reader verifying the room's signature for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the profile form does, because by the time either is signed there is no telling them apart. A second path -- a switch on kind that wrote the pasted event into the right place -- would have been a second reading of the same rows, and two readings of one signature drift. So the persistence half of this feature is a test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1, kind 31889 and kind 10002 each through a real FROST quorum signature, in the shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`, `GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out, under the group's key and coordinate rather than the pasted one. **What the paste says about its author and its time is ignored, and the screen says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`: what goes to `proposeSigning` is the kind, the tags and the content, and the session resolves the room's key, hashes the id over it and stamps the time it opens at -- which is what every typed editor does too. `created_at` in particular had to go. Every kind here is replaceable or addressable, so a pasted timestamp older than the group's last event would make the new one *lose* to the old on every reader and the proposal would look as though it had done nothing. The explanatory line under the byline names both facts, because a paste with a `pubkey` in it is the one case where a member could reasonably expect otherwise. **Only kinds the screen has a place for, and only events that place would accept -- checked before the quorum is asked, not after.** A signature is the most expensive thing this app does, and the readers refuse rather than repair: a kind 0 whose content is not a profile, a blank kind 1, a kind 31889 with no visibility would each be signed, filed and shown nowhere, with nothing to tell the member their quorum was spent on nothing. So `GroupEventProposal.read` makes every check a reader makes, on the paste, and refuses with the reader's own reason -- the schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of kinds with a section on the group's screen: 0, 1, the four relay lists and 31889. Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate or a key state event has invariants a paste cannot be trusted to meet and a place in the room's life that a form is not, and the nip30303 kinds have editors of their own. A long-form article is a perfectly good event the group could sign; it is refused because the line is "has somewhere to land", and the refusal names the kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing groups are entitled to do, and an empty relay list is accepted, since it is a withdrawal the relays card shows as "no relays" rather than as nothing -- the same two lines `GroupNostrProfile.of` and `GroupRelayList` already draw. **The preview is the signing screen's own words.** Above the paste is what the group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1 field" -- read live on every change, since a paste is a few kilobytes and one JSON parse is cheaper than a debounce that would hide the answer for a beat. It is `ProposedEvent.summarize` on the reading, not a description of this screen's own, so that what a member reads before proposing is word for word what every other member reads before signing. Two descriptions of one event would drift. **And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay lists.** The three features before this one added none for their kinds, so a member asked to sign the group's profile has been shown "Event of kind 0" over a line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A profile is now said by its name and what it says about itself, with "An empty profile" and "Unreadable profile" kept apart because a signer should know which; a post by its words, the same call the translated-passage arm makes; a relay list by which of the four it is and its hosts, or "No relays" for a withdrawal. `ProposedEventTest` is new and pins all of them, the schema arm from the last commit included, and that an unrecognised kind keeps its number -- refusing to describe an event is better than describing it wrongly. **The paste stays on every refusal.** A refused reading is drawn under the field and the button, pressed anyway, is answered rather than ignored; a failed proposal is a snackbar over the same paste. The JSON is the thing worth keeping, and a member who pasted two kilobytes of schema should not have to find them again because a visibility tag was missing. The field is set in a monospace face for the same reason: it is JSON, and a bracket that does not line up is what a member is looking for in it. `isActionPending` guards a double press, because a pasted kind 1 accumulates like any other post and a second session over the same paste is a second post on the record forever. **The byline is the group's**, as on the post composer and for its reason: whatever is pasted goes out in the group's name, and a paste naming some other `pubkey` goes out in the group's name anyway. 23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and what is dropped, junk named as junk, the accepted set, an unreadable profile against an empty one, a blank post, a schema refused with the reader's reasons, a relay list with or without relays -- and the four signed chains that are this commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer. `ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview, the two refusals and the inert button for a member with no share, and two more cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block and take it away from a member with no share and from a NIP-17 room. 1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 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@392b90b8d5
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import press.mantra.compose.network.serialization.CommonJson
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaProblem
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
/**
|
||||
* An unsigned event a member pasted for the group to sign, read and checked
|
||||
* before a quorum is asked for it.
|
||||
*
|
||||
* The typed editors -- the profile form, the post composer, the relay and
|
||||
* schema editors -- each build one kind of event and know what it needs. This
|
||||
* is the untyped way in: the event as JSON, the way another tool prints it
|
||||
* (`npm run schema:dry` in the bitcoin.mov repo prints exactly the schema event
|
||||
* it would publish), pasted whole. What comes out is the same thing the editors
|
||||
* produce, a kind, tags and content for `FrostSigningRepository.proposeSigning`,
|
||||
* and it is filed the same way when the quorum signs: `FrostSigningManager.complete`
|
||||
* records it as a `GroupSignedEvent` and the group's screen reads its profile,
|
||||
* posts, relay lists and schemas straight off those by kind.
|
||||
*
|
||||
* ### What is ignored
|
||||
*
|
||||
* `id`, `pubkey`, `sig` and `created_at`. The group is the author -- the
|
||||
* session resolves the room's key and hashes the id over it -- and the time is
|
||||
* when the session opens, which is what every typed editor does too. A pasted
|
||||
* `created_at` in particular has to go: the kinds here are replaceable or
|
||||
* addressable, so a stale one would make the new event *older* than the last and
|
||||
* the proposal would look as though it did nothing. The screen says so.
|
||||
*
|
||||
* ### What is refused, and why before rather than after
|
||||
*
|
||||
* Only kinds the group's screen has a place for, and only events that place
|
||||
* would accept. A signature is the most expensive thing this app does, and the
|
||||
* readers refuse rather than repair: a kind 0 whose content is not a profile,
|
||||
* a blank post, a schema with no visibility would each be signed, filed and
|
||||
* shown nowhere, with nothing to tell the member their quorum was spent on
|
||||
* nothing. So every check a reader makes is made here first, on the paste.
|
||||
*/
|
||||
object GroupEventProposal {
|
||||
/**
|
||||
* The kinds a pasted event may be: exactly the ones the group's screen reads.
|
||||
*
|
||||
* Not the kinds `ChatMessage.applyInnerEvent` has an arm for. A subgroup
|
||||
* certificate or a key state event has invariants a paste cannot be trusted
|
||||
* to meet and a place in the room's life that a form is not, and the
|
||||
* nip30303 kinds have editors of their own. The line is "has a section on the
|
||||
* group's screen", because that is where a member goes to see it landed.
|
||||
*/
|
||||
val ACCEPTED_KINDS: Set<Kind> =
|
||||
setOf(MetadataEvent.KIND, TextNoteEvent.KIND, CuratedSchemaEvent.KIND) + GroupRelaySet.KINDS
|
||||
|
||||
sealed interface Reading
|
||||
|
||||
/** An event the group could sign and the screen could show. */
|
||||
class Ready(
|
||||
val kind: Kind,
|
||||
val tags: Array<Array<String>>,
|
||||
val content: String,
|
||||
) : Reading {
|
||||
/**
|
||||
* The event as the signing screen will describe it, for a preview before
|
||||
* the button is pressed. The id, author and signature are placeholders:
|
||||
* `ProposedEvent.summarize` reads none of them.
|
||||
*/
|
||||
fun preview(): Event = Event(
|
||||
id = "",
|
||||
pubKey = "",
|
||||
createdAt = 0,
|
||||
kind = kind,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "",
|
||||
)
|
||||
}
|
||||
|
||||
/** Why the paste cannot be proposed as it is. */
|
||||
sealed interface Refused : Reading {
|
||||
data object NotJson : Refused
|
||||
|
||||
/** JSON, but an array, a string, a number -- anything but an object. */
|
||||
data object NotAnObject : Refused
|
||||
|
||||
data object NoKind : Refused
|
||||
|
||||
data class KindNotAccepted(val kind: Kind) : Refused
|
||||
|
||||
/** `tags` present but not a list of lists of strings. */
|
||||
data object TagsMalformed : Refused
|
||||
|
||||
/** `content` present but not a string. */
|
||||
data object ContentNotText : Refused
|
||||
|
||||
/** A kind 0 whose content will not parse as a profile. */
|
||||
data object ProfileUnreadable : Refused
|
||||
|
||||
/** A kind 1 with nothing to say, which the posts reader would drop. */
|
||||
data object PostBlank : Refused
|
||||
|
||||
/** A kind 31889 the schema reader would refuse, with its reasons. */
|
||||
data class SchemaUnusable(val problems: List<CuratedSchemaProblem>) : Refused
|
||||
}
|
||||
|
||||
private const val KEY_KIND = "kind"
|
||||
private const val KEY_TAGS = "tags"
|
||||
private const val KEY_CONTENT = "content"
|
||||
|
||||
/** The paste as something the group could sign, or the reason it is not. */
|
||||
fun read(json: String): Reading {
|
||||
val element = runCatching { CommonJson.parseToJsonElement(json) }.getOrNull()
|
||||
?: return Refused.NotJson
|
||||
|
||||
val obj = element as? JsonObject ?: return Refused.NotAnObject
|
||||
|
||||
// A number or a numeric string: relays write the former and a hand
|
||||
// types either, and there is nothing to be gained by refusing "1".
|
||||
val kind = (obj[KEY_KIND] as? JsonPrimitive)?.intOrNull ?: return Refused.NoKind
|
||||
if (kind !in ACCEPTED_KINDS) return Refused.KindNotAccepted(kind)
|
||||
|
||||
val tags = when (val raw = obj[KEY_TAGS]) {
|
||||
null -> emptyArray()
|
||||
else -> readTags(raw) ?: return Refused.TagsMalformed
|
||||
}
|
||||
|
||||
val content = when (val raw = obj[KEY_CONTENT]) {
|
||||
null -> ""
|
||||
else -> (raw as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
?: return Refused.ContentNotText
|
||||
}
|
||||
|
||||
return when (kind) {
|
||||
// An empty kind 0 is a profile the group has wiped, which it is
|
||||
// entitled to do; content that is not JSON is not a profile at all.
|
||||
// The same line `GroupNostrProfile.of` draws.
|
||||
MetadataEvent.KIND -> if (metadataOf(tags, content) == null) {
|
||||
Refused.ProfileUnreadable
|
||||
} else {
|
||||
Ready(kind, tags, content)
|
||||
}
|
||||
|
||||
// The line `AddGroupPostViewModel.proposePost` draws, for the reason
|
||||
// it gives: a blank post cannot be told from a broken one and, kind:1
|
||||
// not being replaceable, cannot be un-said.
|
||||
TextNoteEvent.KIND -> if (content.isBlank()) {
|
||||
Refused.PostBlank
|
||||
} else {
|
||||
Ready(kind, tags, content)
|
||||
}
|
||||
|
||||
CuratedSchemaEvent.KIND -> CuratedSchemaEvent.read(tags, content).problems().let { problems ->
|
||||
if (problems.isEmpty()) Ready(kind, tags, content) else Refused.SchemaUnusable(problems)
|
||||
}
|
||||
|
||||
// A relay list with no relay in it is a list the group has withdrawn,
|
||||
// which the relays card shows as "no relays" rather than as nothing.
|
||||
else -> Ready(kind, tags, content)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTags(raw: JsonElement): Array<Array<String>>? {
|
||||
val outer = raw as? JsonArray ?: return null
|
||||
|
||||
return outer.map { tag ->
|
||||
val inner = tag as? JsonArray ?: return null
|
||||
inner.map { value ->
|
||||
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull ?: return null
|
||||
}.toTypedArray()
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
private fun metadataOf(tags: Array<Array<String>>, content: String) = MetadataEvent(
|
||||
id = "",
|
||||
pubKey = "",
|
||||
createdAt = 0,
|
||||
tags = tags,
|
||||
content = content,
|
||||
sig = "",
|
||||
).contactMetaData()
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package press.mantra.compose.text
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import press.mantra.compose.nostr.curated.CuratedSchemaEvent
|
||||
import press.mantra.compose.nostr.frost.GroupKeyStateEvent
|
||||
import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent
|
||||
@@ -156,6 +159,39 @@ object ProposedEvent {
|
||||
).joinToString(" · ")
|
||||
)
|
||||
|
||||
// The group's own profile. Named by what the group would be called and
|
||||
// what it would say about itself, which is the whole of what is being
|
||||
// decided; the JSON in the content is the one thing a signer should not
|
||||
// have to read to find that out.
|
||||
MetadataEvent.KIND -> Summary(
|
||||
label = "Nostr profile",
|
||||
detail = MetadataEvent(
|
||||
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
|
||||
).contactMetaData()?.let { metadata ->
|
||||
listOfNotNull(
|
||||
metadata.anyName()?.takeIf { it.isNotBlank() },
|
||||
metadata.about?.takeIf { it.isNotBlank() }
|
||||
).joinToString(" · ").ifEmpty { "An empty profile" }
|
||||
} ?: "Unreadable profile"
|
||||
)
|
||||
|
||||
// A post in the group's name. The words are what is being decided, so
|
||||
// they are the detail rather than a count of them -- the same call the
|
||||
// translated-passage arm makes.
|
||||
TextNoteEvent.KIND -> Summary(label = "Post", detail = event.content)
|
||||
|
||||
// One of the group's relay lists, shown as hosts: every entry in one
|
||||
// starts wss://, so the scheme is the part that never tells two apart.
|
||||
// An empty one is a list the group is withdrawing, and says so.
|
||||
in GroupRelaySet.KINDS -> {
|
||||
val set = GroupRelaySet.entries.first { it.kind == event.kind }
|
||||
|
||||
Summary(
|
||||
label = "${set.name} relay list",
|
||||
detail = set.parse(event.tags).joinToString { it.displayUrl() }.ifEmpty { "No relays" }
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -24,6 +24,7 @@ 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.DataObject
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
import androidx.compose.material.icons.filled.LibraryBooks
|
||||
@@ -78,6 +79,7 @@ import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
|
||||
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
|
||||
@@ -108,6 +110,7 @@ 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.propose_event
|
||||
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
|
||||
@@ -614,6 +617,44 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The untyped way into everything above it. Each
|
||||
// section has a form that builds one kind of event;
|
||||
// this takes any of those kinds as JSON, pasted from
|
||||
// wherever it was made, and it lands in whichever
|
||||
// section is its kind's once the quorum signs --
|
||||
// the same reading, off the same signed events.
|
||||
// Last in the block because it is about the block
|
||||
// rather than about any one section of it, and
|
||||
// behind the same gate for the same reason.
|
||||
if (chatRoomDetailUIState.canEditNostrProfile) {
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
ProposeGroupEventRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DataObject,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(
|
||||
MaterialTheme.spacing.space125
|
||||
)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.propose_event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
@@ -875,7 +875,7 @@ private fun CuratedFieldType.label(): StringResource = when (this) {
|
||||
* sentence and the check cannot disagree about the number.
|
||||
*/
|
||||
@Composable
|
||||
private fun CuratedSchemaProblem.message(): String = when (this) {
|
||||
internal fun CuratedSchemaProblem.message(): String = when (this) {
|
||||
CuratedSchemaProblem.IdentifierMissing -> stringResource(Res.string.an_identifier_is_required)
|
||||
CuratedSchemaProblem.IdentifierTooLong -> stringResource(
|
||||
Res.string.the_identifier_must_be_at_most_n_characters,
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
package press.mantra.compose.ui.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.BottomAppBarDefaults
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import press.mantra.compose.database.model.ChatRoom
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupEventProposal
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.text.ProposedEvent
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.composable.widgets.ScreenStateTransition
|
||||
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
|
||||
import press.mantra.compose.ui.composable.widgets.rememberNotifier
|
||||
import press.mantra.compose.ui.theme.ConformancePreviews
|
||||
import press.mantra.compose.ui.theme.MantraTheme
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
import press.mantra.compose.ui.theme.spacing
|
||||
import press.mantra.compose.ui.view.model.ProposeGroupEventViewModel
|
||||
import press.mantra.compose.ui.view.state.ProposeGroupEventUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
import mantra.composeapp.generated.resources.a_post_needs_something_to_say
|
||||
import mantra.composeapp.generated.resources.an_event_is_a_json_object
|
||||
import mantra.composeapp.generated.resources.an_event_needs_a_kind
|
||||
import mantra.composeapp.generated.resources.content_must_be_text
|
||||
import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_event
|
||||
import mantra.composeapp.generated.resources.eg_kind_1_tags_content
|
||||
import mantra.composeapp.generated.resources.kind_n
|
||||
import mantra.composeapp.generated.resources.kind_n_has_nowhere_to_go_on_this_screen
|
||||
import mantra.composeapp.generated.resources.paste_an_unsigned_event_for_the_group_to_sign
|
||||
import mantra.composeapp.generated.resources.propose_event
|
||||
import mantra.composeapp.generated.resources.tags_must_be_a_list_of_lists_of_strings
|
||||
import mantra.composeapp.generated.resources.that_is_not_json
|
||||
import mantra.composeapp.generated.resources.the_content_is_not_a_profile
|
||||
import mantra.composeapp.generated.resources.the_group_signs_an_event_so_it_takes_a_quorum
|
||||
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_an_event
|
||||
import mantra.composeapp.generated.resources.this_schema_cannot_be_signed_as_it_is
|
||||
import mantra.composeapp.generated.resources.unsigned_event
|
||||
import mantra.composeapp.generated.resources.what_the_group_would_sign
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* An unsigned event, pasted, for the group to sign.
|
||||
*
|
||||
* The typed editors each build one kind of statement; this takes any of the
|
||||
* kinds the group's screen can show, as JSON, the way another tool prints one.
|
||||
* Above the paste is what the group would be signing, read back the way the
|
||||
* signing screen will describe it, so a member sees "Curated schema · bitcoin.mov"
|
||||
* rather than a wall of brackets before deciding -- and sees why the group will
|
||||
* not be asked when the paste is something it could never show.
|
||||
*
|
||||
* The byline is the group's, as on the post composer and for the same reason:
|
||||
* whatever is pasted goes out in the group's name, and a paste that names some
|
||||
* other `pubkey` goes out in the group's name anyway. The explanatory line says
|
||||
* so, because a paste with a `pubkey` in it is the one case where a member could
|
||||
* reasonably expect otherwise.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProposeGroupEventScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialProposeGroupEventUIState: ProposeGroupEventUIState = ProposeGroupEventUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
) {
|
||||
val proposeGroupEventViewModel: ProposeGroupEventViewModel = viewModel(
|
||||
factory = ProposeGroupEventViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialProposeGroupEventUIState = initialProposeGroupEventUIState,
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
)
|
||||
|
||||
// Read out here rather than in the click handler: both are composable and an
|
||||
// onClick lambda is not. The scope is the caller's so a message survives this
|
||||
// screen being replaced by the signing session.
|
||||
val notify = rememberNotifier(rememberCoroutineScope())
|
||||
val couldNotPropose = stringResource(Res.string.could_not_ask_the_group_to_sign_this_event)
|
||||
|
||||
ScreenStateTransition(proposeGroupEventViewModel.proposeGroupEventUIState) { uiState ->
|
||||
when (val proposeGroupEventUIState = uiState) {
|
||||
is ProposeGroupEventUIState.Error -> {
|
||||
// Nothing to retry: the room came from a navigation argument, and
|
||||
// reading it again with the same one fails the same way.
|
||||
ErrorState(message = proposeGroupEventUIState.message, onRetry = null)
|
||||
}
|
||||
|
||||
is ProposeGroupEventUIState.Loaded -> {
|
||||
val eventFieldState = rememberTextFieldState()
|
||||
val canSign = proposeGroupEventUIState.canSign
|
||||
|
||||
// Read on every change. A paste is a few kilobytes at most and the
|
||||
// reading is one JSON parse, so this is cheaper than the debounce
|
||||
// that would hide the answer for a beat after the paste.
|
||||
val json = eventFieldState.text.toString()
|
||||
val reading = remember(json) {
|
||||
json.takeIf { it.isNotBlank() }?.let(GroupEventProposal::read)
|
||||
}
|
||||
|
||||
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
|
||||
// other button in the app uses rather than inventing a shade here.
|
||||
val buttonColors = ButtonDefaults.buttonColors()
|
||||
|
||||
// imePadding: this screen is a text field the size of the screen,
|
||||
// and without it the software keyboard covers what is being pasted.
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
modifier = Modifier.imePadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(Res.string.propose_event)) }
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
BottomAppBar(
|
||||
actions = {},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
modifier = if (canSign) {
|
||||
Modifier
|
||||
} else {
|
||||
// Looking unavailable is not being unavailable.
|
||||
Modifier.semantics { disabled() }
|
||||
},
|
||||
containerColor = if (canSign) {
|
||||
FloatingActionButtonDefaults.containerColor
|
||||
} else {
|
||||
buttonColors.disabledContainerColor
|
||||
},
|
||||
contentColor = if (canSign) {
|
||||
contentColorFor(FloatingActionButtonDefaults.containerColor)
|
||||
} else {
|
||||
buttonColors.disabledContentColor
|
||||
},
|
||||
onClick = {
|
||||
if (!canSign) return@ExtendedFloatingActionButton
|
||||
|
||||
proposeGroupEventViewModel.proposeEvent(
|
||||
localChatRoom = proposeGroupEventUIState.localChatRoom,
|
||||
json = eventFieldState.text.toString(),
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back
|
||||
// to the group. Nothing has been
|
||||
// signed yet -- the event lands when
|
||||
// a quorum signs -- so landing on a
|
||||
// screen that does not show it would
|
||||
// read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
// The reason is already under the paste;
|
||||
// pressing the button anyway is answered
|
||||
// rather than ignored, and the paste
|
||||
// stays. Same on a failure to propose.
|
||||
onRefused = {},
|
||||
onFailure = { notify(couldNotPropose) }
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Draw, contentDescription = "Propose event")
|
||||
Text(stringResource(Res.string.propose_event))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Whose name this goes out in, said before anything is
|
||||
// pasted. It is the group's, whatever the paste says.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProfileAvatar(
|
||||
size = MaterialTheme.spacing.space500,
|
||||
publicKey = chatRoomId,
|
||||
picture = proposeGroupEventUIState.groupNostrProfile?.picture(),
|
||||
name = proposeGroupEventUIState.groupNostrProfile?.name()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
|
||||
|
||||
Text(
|
||||
text = proposeGroupEventUIState.groupNostrProfile?.name()
|
||||
?: proposeGroupEventUIState.localChatRoom.chatRoom.subject
|
||||
?: chatRoomId.take(16),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.paste_an_unsigned_event_for_the_group_to_sign
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.the_group_signs_an_event_so_it_takes_a_quorum
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
if (!canSign) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.this_group_has_no_shared_key_to_sign_an_event
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
when (reading) {
|
||||
null -> Unit
|
||||
is GroupEventProposal.Ready -> ProposalPreview(reading)
|
||||
is GroupEventProposal.Refused -> RefusalNotice(reading)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = eventFieldState,
|
||||
// The paste grows into the screen rather than scrolling
|
||||
// inside three lines, and is set in a monospace face:
|
||||
// it is JSON, and a bracket that does not line up is
|
||||
// the kind of thing a member is looking for in it.
|
||||
lineLimits = TextFieldLineLimits.MultiLine(),
|
||||
textStyle = TextStyle(fontFamily = FontFamily.Monospace),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
label = { Text(stringResource(Res.string.unsigned_event)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(Res.string.eg_kind_1_tags_content),
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
},
|
||||
isError = reading is GroupEventProposal.Refused
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProposeGroupEventUIState.Loading -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.screenMargin),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sectionGap)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.emphasisGap))
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.propose_event),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialProposeGroupEventUIState == ProposeGroupEventUIState.Loading) {
|
||||
proposeGroupEventViewModel.initiateProposeGroupEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the group would sign, said the way the signing screen will say it.
|
||||
*
|
||||
* `ProposedEvent.summarize` rather than a description of this screen's own, so
|
||||
* that what a member reads here before proposing is word for word what every
|
||||
* other member reads before signing. Two descriptions of one event would drift.
|
||||
*/
|
||||
@Composable
|
||||
private fun ProposalPreview(ready: GroupEventProposal.Ready) {
|
||||
val summary = ProposedEvent.summarize(ready.preview())
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.what_the_group_would_sign),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.kind_n, ready.kind.toString()) + " · " + summary.label,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
// Capped, because a kind:1's detail is the whole post and the paste
|
||||
// under this card is the thing the screen is for.
|
||||
Text(
|
||||
text = summary.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the group will not be asked, under the paste it is about.
|
||||
*
|
||||
* Named rather than silent: a button that does nothing for a paste it refuses is
|
||||
* indistinguishable from a missed tap. A schema's reasons are the same sentences
|
||||
* the schema editor uses, since they are the same checks.
|
||||
*/
|
||||
@Composable
|
||||
private fun RefusalNotice(refused: GroupEventProposal.Refused) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)
|
||||
) {
|
||||
Text(
|
||||
text = when (refused) {
|
||||
GroupEventProposal.Refused.NotJson -> stringResource(Res.string.that_is_not_json)
|
||||
GroupEventProposal.Refused.NotAnObject -> stringResource(Res.string.an_event_is_a_json_object)
|
||||
GroupEventProposal.Refused.NoKind -> stringResource(Res.string.an_event_needs_a_kind)
|
||||
is GroupEventProposal.Refused.KindNotAccepted -> stringResource(
|
||||
Res.string.kind_n_has_nowhere_to_go_on_this_screen,
|
||||
refused.kind.toString(),
|
||||
GroupEventProposal.ACCEPTED_KINDS.sorted().joinToString()
|
||||
)
|
||||
GroupEventProposal.Refused.TagsMalformed ->
|
||||
stringResource(Res.string.tags_must_be_a_list_of_lists_of_strings)
|
||||
GroupEventProposal.Refused.ContentNotText -> stringResource(Res.string.content_must_be_text)
|
||||
GroupEventProposal.Refused.ProfileUnreadable ->
|
||||
stringResource(Res.string.the_content_is_not_a_profile)
|
||||
GroupEventProposal.Refused.PostBlank -> stringResource(Res.string.a_post_needs_something_to_say)
|
||||
is GroupEventProposal.Refused.SchemaUnusable ->
|
||||
stringResource(Res.string.this_schema_cannot_be_signed_as_it_is)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
if (refused is GroupEventProposal.Refused.SchemaUnusable) {
|
||||
refused.problems.forEach { problem ->
|
||||
Text(
|
||||
text = problem.message(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun ProposeGroupEventScreenPreview() {
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ProposeGroupEventScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = "publicKey",
|
||||
relayHint = null,
|
||||
initialProposeGroupEventUIState = ProposeGroupEventUIState.Loaded(
|
||||
localChatRoom = LocalChatRoom(
|
||||
chatRoom = ChatRoom(
|
||||
id = "publicKey",
|
||||
userPublicKey = "",
|
||||
subject = "Translation room",
|
||||
description = "A group translating hard books.",
|
||||
initialGiftWrapPayloadId = "sdfaer",
|
||||
mlsGroupState = "state"
|
||||
),
|
||||
),
|
||||
// A group that can sign, so the screen renders in the state a
|
||||
// member actually meets it in rather than greyed out.
|
||||
canSign = true
|
||||
),
|
||||
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
|
||||
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
|
||||
onNavigateToRouteAndPopUpInclusive = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
|
||||
import press.mantra.compose.ui.composable.AddGroupPostScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
|
||||
import press.mantra.compose.ui.composable.ProposeGroupEventScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupRelaysScreen
|
||||
import press.mantra.compose.ui.composable.HomeScreen
|
||||
import press.mantra.compose.ui.composable.ImplementationPendingScreen
|
||||
@@ -129,6 +130,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupCuratedSchemaRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposeGroupEventRoute
|
||||
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
|
||||
@@ -1057,6 +1059,26 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<ProposeGroupEventRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<ProposeGroupEventRoute>()
|
||||
|
||||
ProposeGroupEventScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace the paste so back returns to the group rather
|
||||
// than to an event whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<ProposeGroupEventRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<FrostSigningRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<FrostSigningRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** The untyped way into a group's signed statements: an unsigned event, pasted. */
|
||||
@Serializable
|
||||
data class ProposeGroupEventRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String, // TODO: have this as a publicKey
|
||||
val relayHint: String?
|
||||
): Route()
|
||||
@@ -0,0 +1,158 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import co.touchlab.kermit.Logger
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupEventProposal
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.ui.view.state.ProposeGroupEventUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Asks the group to sign an event a member pasted.
|
||||
*
|
||||
* Nothing is built here; the paste is the event. What this decides is whether
|
||||
* the group should be asked at all -- `GroupEventProposal.read` refuses a paste
|
||||
* the group's screen could never show, so a quorum is not spent on an event that
|
||||
* would be signed, filed and seen by nobody -- and it hands the kind, tags and
|
||||
* content to the same `proposeSigning` every typed editor uses. The author and
|
||||
* the time are the session's to fill in, which is how a pasted `pubkey` or
|
||||
* `created_at` comes to be ignored: they are never passed along.
|
||||
*
|
||||
* Where the event lands is decided by nothing here. `FrostSigningManager.complete`
|
||||
* files every signed event as a `GroupSignedEvent`, and the group's screen reads
|
||||
* its profile, posts, relay lists and schemas off those by kind. A pasted kind 0
|
||||
* becomes the profile the same way a kind 0 from the profile form does, because
|
||||
* by the time either is signed there is no telling them apart.
|
||||
*/
|
||||
class ProposeGroupEventViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialProposeGroupEventUIState: ProposeGroupEventUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var proposeGroupEventUIState: ProposeGroupEventUIState by mutableStateOf(
|
||||
initialProposeGroupEventUIState
|
||||
)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
fun initiateProposeGroupEvent() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
proposeGroupEventUIState = if (localChatRoom == null) {
|
||||
ProposeGroupEventUIState.Error("Couldn't find the chat room")
|
||||
} else {
|
||||
ProposeGroupEventUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
|
||||
// A NIP-17 room has no key of its own to sign as, so it has no
|
||||
// statements to make -- the same condition the group detail
|
||||
// screen gates the entry point on.
|
||||
canSign = localChatRoom.chatRoom.mlsGroupState != null &&
|
||||
frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a session over the pasted event, or says why not.
|
||||
*
|
||||
* [onRefused] carries the reading's own reason, which the screen has been
|
||||
* showing under the paste all along; it is repeated here so pressing the
|
||||
* button on a refused paste is answered rather than ignored. Both refusals
|
||||
* and a failed proposal stay on the paste: the JSON is the thing worth
|
||||
* keeping.
|
||||
*/
|
||||
fun proposeEvent(
|
||||
localChatRoom: LocalChatRoom,
|
||||
json: String,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onRefused: (GroupEventProposal.Refused) -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
// Guard against double submits. A pasted kind:1 accumulates like any
|
||||
// other post, so a second session over the same paste is a second post
|
||||
// on the record forever.
|
||||
if (isActionPending.value) return
|
||||
|
||||
val ready = when (val reading = GroupEventProposal.read(json)) {
|
||||
is GroupEventProposal.Ready -> reading
|
||||
is GroupEventProposal.Refused -> {
|
||||
onRefused.invoke(reading)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = ready.kind,
|
||||
tags = ready.tags,
|
||||
content = ready.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a pasted kind ${ready.kind} event 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 = "ProposeGroupEventViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialProposeGroupEventUIState: ProposeGroupEventUIState =
|
||||
ProposeGroupEventUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
ProposeGroupEventViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialProposeGroupEventUIState = initialProposeGroupEventUIState,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
|
||||
sealed interface ProposeGroupEventUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
/**
|
||||
* Who the group says it is, for the byline over the paste.
|
||||
*
|
||||
* Whatever is pasted goes out signed by the group and read as the group,
|
||||
* so the screen shows whose name it will carry -- which is not the
|
||||
* member pasting it, and not whatever `pubkey` the paste may name.
|
||||
*/
|
||||
val groupNostrProfile: GroupNostrProfile? = null,
|
||||
/**
|
||||
* Whether this device holds a share of the group's key.
|
||||
*
|
||||
* False leaves the paste writable and the button inert, for the same
|
||||
* reason the typed editors do: reading and checking cost nothing, and
|
||||
* only a share-holder can open the session that would sign it.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): ProposeGroupEventUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): ProposeGroupEventUIState
|
||||
|
||||
data object Loading: ProposeGroupEventUIState
|
||||
}
|
||||
Reference in New Issue
Block a user