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:
Kgothatso Ngako
2026-09-11 16:58:59 +02:00
parent f8e5d3610d
commit 26de13b010
14 changed files with 1554 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
import press.mantra.compose.ui.composable.AddGroupPostScreen
import press.mantra.compose.ui.composable.EditGroupCuratedSchemaScreen
import press.mantra.compose.ui.composable.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>()

View File

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

View File

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

View File

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