feat(groups): what the group says out loud, and the one arm that must not swallow a message
`4c0ed0c1` gave a group a name and an address on nostr and nothing to say from either. This is the third statement that identity is made of: kind:1s authored by the room's own key, listed under the relay lists on the group's screen, with a composer behind "Add post" that proposes the next one to the quorum. The section sits under Relays and above Library on purpose. The profile says who the group is, the relay lists say where to find it, and these are what a reader would find there -- so they finish the identity block, and the library below them is the group's *work* rather than its voice. **A post is the group speaking; the transcript is a member speaking.** Those are different things and the room already had the second. A room's messages are `ChatEvent` kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any reader can check the group said it and that no single member could have made it say so. The composer's byline is the whole of that design: every other composer in this app puts the writer's name over the draft because the writer is the author, and doing that here would have a member writing what looks like their own note and finding the group had said it. The byline is the group's profile picture and name, shown before a word is typed. **The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is dangerous in the opposite direction to the last two.** Every kind the group signs needs an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not like kind:0 or a relay list: those are identity plumbing nobody sends into a room on purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has rendered as an `unsupported` event since long before any of this existed. So this arm verifies, and on failure **falls through to `unsupported` rather than dropping the event** -- which required naming that branch as a local `fun unsupported()` so one arm can reach it deliberately instead of only falling off the end of the `when`. Getting this wrong would have been this change quietly deleting messages it has no business touching, and it would have looked like nothing at all. **Verified, not merely attributed** -- the same rule the other two arms ended up at. A rumor carries an empty signature and any pubkey its sender likes, so an author check alone would let one member write "the group posted in its own name" into the room's own transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says. **Posts accumulate; everything else in this feature replaces.** kind:1 is not replaceable, so a second post stands beside the first rather than superseding it, and every "newest wins" the profile and the relay lists are built on is wrong for these. `newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts survive all three orderings a query could hand them over in -- a reading that kept only the newest would silently delete a group's entire history the first time it posted twice, and would look like a feature until somebody noticed. **Blank posts are refused twice.** The composer will not propose one, because a quorum signing an empty note puts a post on the record that renders as nothing, cannot be told from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And `newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from outside this app is in exactly the same position. **A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag `TextNoteEvent.build` can add describes a relationship to something else that a group's first way of posting does not have and should not guess at. The mark is for an event that arrived some other way: drawing a reply as an announcement would put half a conversation on a group's page with nothing saying it was half. **The card shows the whole note.** A post is short by construction and it is the one thing on that screen which exists to be *read*; truncating it would mean tapping through to see a paragraph. The composer grows into the screen for the same reason -- a three-line box that hides what was written two sentences ago is a box nobody proofreads in. **No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both `NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off `GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete` already files every signed event before applying it, so no table, no migration, and one more kind-scoped query. **Reading a post needs no share of the key; proposing one does.** A post is the one statement here meant for people who were not in the room, so a member holding no share reads the section like anybody else and simply gets no "Add post". Both gates are `canEditNostrProfile`, which is `canSign` read once for what is now four entry points. **And the caveat that bites hardest here: nothing has reached a relay.** `FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible to its members and to nobody else -- for a profile that is an inconvenience, for a post it is most of the point. The relay lists directly above the section say where these should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms rather than the neutral ones the other two readers use. `TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a `RitualNotice` like the other two -- nobody said it, the group signed it. The line names the post rather than quoting it: the post itself is on the group's screen where it can be read whole, and a quote would be the same words twice with the second copy unable to say who agreed to them. 10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape `FrostSigningManager.advance` runs, and three more in the section layout test -- ordering on screen, the empty state, and a member with no share reading posts they cannot add to. 1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`, 13 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@334e6dd1cb
This commit is contained in:
@@ -21,6 +21,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
@@ -422,9 +423,21 @@ data class ChatMessage(
|
||||
* renders as a chat bubble, silently, looking exactly like somebody having
|
||||
* said "The group is now called Translation collective".
|
||||
*/
|
||||
/**
|
||||
* The group signed something to say publicly, in its own name.
|
||||
*
|
||||
* A system line rather than a bubble, like the other two -- nobody said it,
|
||||
* the group signed it -- and the line names the post rather than repeating
|
||||
* it, because the post itself is on the group's screen where it can be read
|
||||
* whole. A quote here would be the same words twice with the second copy
|
||||
* unable to say who agreed to them.
|
||||
*/
|
||||
const val TYPE_GROUP_POST_SIGNED = "groupPostSigned"
|
||||
|
||||
val GROUP_IDENTITY_TYPES = setOf(
|
||||
TYPE_GROUP_PROFILE_SIGNED,
|
||||
TYPE_GROUP_RELAYS_SIGNED,
|
||||
TYPE_GROUP_POST_SIGNED,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -977,6 +990,22 @@ data class ChatMessage(
|
||||
createdAt: Instant,
|
||||
groupSignedEventId: HexKey? = null,
|
||||
): ChatMessage? {
|
||||
// The last resort, named because one arm reaches it deliberately rather
|
||||
// than by falling off the end: a kind:1 the room did not sign is a
|
||||
// member's own note travelling through the room, and it has always
|
||||
// rendered as this. See the `TextNoteEvent` arm.
|
||||
fun unsupported() = ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "unsupported",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = event.toJson(),
|
||||
)
|
||||
|
||||
return when (event.kind) {
|
||||
ChatEvent.KIND -> {
|
||||
ChatMessage(
|
||||
@@ -1429,6 +1458,41 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
|
||||
// The group saying something publicly, in its own name -- which
|
||||
// is a different thing from a member saying something in the room.
|
||||
// A room's messages are `ChatEvent`s; this is a kind:1 authored by
|
||||
// the room's own key, so any reader can check the group said it and
|
||||
// that no single member could have.
|
||||
//
|
||||
// Verified like the arms below and for the same reason: a rumor
|
||||
// carries an empty signature and any pubkey its sender likes, so an
|
||||
// author check alone would let one member put words in the group's
|
||||
// mouth in its own transcript.
|
||||
//
|
||||
// **Unlike those arms, a kind:1 that fails falls through to
|
||||
// `unsupported` rather than vanishing.** A member's kind:0 or relay
|
||||
// list passing through a room is identity plumbing nobody sent on
|
||||
// purpose and silence is the right treatment; a kind:1 is content,
|
||||
// somebody meant it, and it has rendered as an unsupported event
|
||||
// since before any of this existed. Dropping it here would be this
|
||||
// change quietly deleting messages it has no business touching.
|
||||
TextNoteEvent.KIND -> {
|
||||
val signed = GroupSignedEvent.fromEvent(event, chatRoomId = groupId)
|
||||
if (!signed.verifies()) return unsupported()
|
||||
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = TYPE_GROUP_POST_SIGNED,
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = "The group posted in its own name"
|
||||
)
|
||||
}
|
||||
|
||||
// The group saying where it lives on nostr: which relays carry
|
||||
// its work, take its messages, answer a search of it, and which it
|
||||
// will not talk to.
|
||||
@@ -1471,19 +1535,7 @@ data class ChatMessage(
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ChatMessage(
|
||||
giftWrapPayloadId = null,
|
||||
messageType = "unsupported",
|
||||
marmotGroupEventId = marmotGroupEventId,
|
||||
marmotInnerEventId = marmotInnerEventId,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isUserMessage = isUserMessage,
|
||||
chatRoomId = groupId,
|
||||
createdAt = createdAt,
|
||||
content = event.toJson(),
|
||||
)
|
||||
}
|
||||
else -> unsupported()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import press.mantra.compose.managers.GroupKeyStateManager
|
||||
import press.mantra.compose.managers.MarmotGroupCreation
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import press.mantra.compose.database.model.ChatMessage
|
||||
@@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
@@ -157,6 +159,17 @@ class DatabaseChatRepository(
|
||||
GroupRelayList.allAmong(emptyList(), chatRoomId)
|
||||
}
|
||||
|
||||
override suspend fun groupPosts(chatRoomId: String): List<GroupPost> = try {
|
||||
GroupPost.newestFirstAmong(
|
||||
signedEvents = database.groupSignedEventDao()
|
||||
.getByChatRoomIdAndKind(chatRoomId, TextNoteEvent.KIND),
|
||||
chatRoomId = chatRoomId,
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
logger.e("Error reading the posts of $chatRoomId", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = try {
|
||||
FrostSigningManager.canSign(database, chatRoomId)
|
||||
} catch (e: Throwable) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package press.mantra.compose.nostr
|
||||
|
||||
import press.mantra.compose.database.model.GroupSignedEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* A kind:1 the group signed: something it said publicly, as itself.
|
||||
*
|
||||
* The room's id is the pubkey it signs as, so a note authored by that key is the
|
||||
* *group* posting rather than a member posting in the group. Those are different
|
||||
* things and the room already has the second one -- the transcript. This is the
|
||||
* first: a statement the group put a quorum's signature to, which any reader can
|
||||
* check came from the group and not from whoever typed it.
|
||||
*
|
||||
* ### Why this is not a `Post` row
|
||||
*
|
||||
* `Post` hangs off both `NostrEvent` and `Profile` by foreign key, and a group has
|
||||
* neither: a group-signed event is not a `NostrEvent` -- see `GroupNostrProfile`,
|
||||
* which gives the reasoning at length -- and the group has no `Profile` row for the
|
||||
* same reason. So a group's posts live where the group's other signed statements
|
||||
* live, and this is the reading of them.
|
||||
*
|
||||
* ### Nothing here has reached a relay
|
||||
*
|
||||
* The same caveat `GroupNostrProfile` and `GroupRelayList` carry, and it bites
|
||||
* hardest here: a post is the one kind of statement whose entire purpose is to be
|
||||
* read by people outside the group. `FrostSigningManager.complete` applies signed
|
||||
* events locally and puts nothing on the wire, so for now a group's posts are
|
||||
* visible to its members and to nobody else. The relay lists beside them say where
|
||||
* they should go; sending them is a job that does not exist yet.
|
||||
*/
|
||||
data class GroupPost(
|
||||
/** The group's statement, whole, with the signature that makes it one. */
|
||||
val signedEvent: GroupSignedEvent,
|
||||
) {
|
||||
/** The group's nostr identity, which for a derived room is also its id. */
|
||||
val publicKey: HexKey get() = signedEvent.publicKey
|
||||
|
||||
/** When the group signed it, which is the order posts are read in. */
|
||||
val postedAt: Instant get() = signedEvent.createdAt
|
||||
|
||||
/** What the group said. */
|
||||
val content: String get() = signedEvent.content
|
||||
|
||||
/**
|
||||
* Whether this is the group speaking rather than answering.
|
||||
*
|
||||
* A kind:1 with no `e` tag starts a thread; one with them is a reply. Nothing
|
||||
* here writes a reply yet -- the composer builds a bare note -- so this exists
|
||||
* to keep an imported or chronicled reply from being drawn as an announcement.
|
||||
*/
|
||||
fun isNewThread(): Boolean = asTextNoteEvent().isNewThread()
|
||||
|
||||
/** The event as the group signed it. */
|
||||
fun asTextNoteEvent(): TextNoteEvent = signedEvent.toEvent().let { event ->
|
||||
TextNoteEvent(
|
||||
id = event.id,
|
||||
pubKey = event.pubKey,
|
||||
createdAt = event.createdAt,
|
||||
tags = event.tags,
|
||||
content = event.content,
|
||||
sig = event.sig,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The reading of one signed event, or null when it is not one of these.
|
||||
*
|
||||
* The same gate the profile and the relay lists use: the wrong kind, or a
|
||||
* signature that does not check out as [chatRoomId]'s, and a caller gets
|
||||
* nothing. A kind:1 a member sent into the room is that member's note, not
|
||||
* the group's, and fails on the author.
|
||||
*/
|
||||
fun of(signedEvent: GroupSignedEvent, chatRoomId: String): GroupPost? {
|
||||
if (signedEvent.kind != TextNoteEvent.KIND) return null
|
||||
if (!signedEvent.verifies()) return null
|
||||
|
||||
return GroupPost(signedEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every post [chatRoomId] signed among [signedEvents], newest first.
|
||||
*
|
||||
* Newest first because that is how a feed is read, and the id breaks a tie
|
||||
* so two devices holding the same posts show them in the same order. Unlike
|
||||
* a profile or a relay list these accumulate rather than replace: kind:1 is
|
||||
* not replaceable, so a second post stands beside the first.
|
||||
*
|
||||
* Blank posts are dropped. A signed note with nothing in it renders as an
|
||||
* empty card that cannot be told from a broken one, and the composer will
|
||||
* not produce one -- so anything that reaches here empty came from
|
||||
* somewhere this app does not control.
|
||||
*/
|
||||
fun newestFirstAmong(
|
||||
signedEvents: List<GroupSignedEvent>,
|
||||
chatRoomId: String,
|
||||
): List<GroupPost> =
|
||||
signedEvents
|
||||
.mapNotNull { of(it, chatRoomId) }
|
||||
.filter { it.content.isNotBlank() }
|
||||
.sortedWith(compareByDescending<GroupPost> { it.postedAt }.thenByDescending {
|
||||
it.signedEvent.id
|
||||
})
|
||||
|
||||
/**
|
||||
* The event to ask the group to sign for a post saying [note].
|
||||
*
|
||||
* A bare kind:1: no reply tags, no mentions, no subject. What the group is
|
||||
* doing here is speaking, and every tag `TextNoteEvent.build` can add
|
||||
* describes a relationship to something else -- a thread to reply into, a
|
||||
* pubkey to notify -- which a group's first way of posting does not have
|
||||
* and should not guess at.
|
||||
*/
|
||||
fun template(note: String): EventTemplate<TextNoteEvent> =
|
||||
TextNoteEvent.build(note = note.trim())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import press.mantra.compose.managers.SharedKeyDerivation
|
||||
import press.mantra.compose.managers.MarmotGroupCreation
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.database.model.Participant
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatMessage
|
||||
@@ -102,6 +103,16 @@ interface ChatRepository {
|
||||
*/
|
||||
suspend fun groupRelayLists(chatRoomId: String): List<GroupRelayList>
|
||||
|
||||
/**
|
||||
* The kind:1s this group signed, newest first.
|
||||
*
|
||||
* The group speaking as itself, which is a different thing from a member
|
||||
* speaking in the room -- the transcript is the second. Unlike the profile and
|
||||
* the relay lists these accumulate rather than replace, so this is a list and
|
||||
* an empty one means the group has said nothing publicly.
|
||||
*/
|
||||
suspend fun groupPosts(chatRoomId: String): List<GroupPost>
|
||||
|
||||
/**
|
||||
* Whether this device holds a share of the group's key, and so could take
|
||||
* part in signing for it.
|
||||
@@ -288,6 +299,8 @@ interface ChatRepository {
|
||||
override suspend fun groupRelayLists(chatRoomId: String): List<GroupRelayList> =
|
||||
GroupRelayList.allAmong(emptyList(), chatRoomId)
|
||||
|
||||
override suspend fun groupPosts(chatRoomId: String): List<GroupPost> = emptyList()
|
||||
|
||||
override suspend fun canSign(chatRoomId: String): Boolean = false
|
||||
|
||||
override suspend fun refuseSubgroup(
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
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.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.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.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.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
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.AddGroupPostViewModel
|
||||
import press.mantra.compose.ui.view.state.AddGroupPostUIState
|
||||
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.add_post
|
||||
import mantra.composeapp.generated.resources.could_not_ask_the_group_to_sign_this_post
|
||||
import mantra.composeapp.generated.resources.propose_post
|
||||
import mantra.composeapp.generated.resources.the_group_signs_a_post_so_it_takes_a_quorum
|
||||
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_to_sign_a_post
|
||||
import mantra.composeapp.generated.resources.what_should_the_group_say
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* The draft of something the group would say, in its own name.
|
||||
*
|
||||
* Not a message in the room: this is signed by the group's key and read by anybody,
|
||||
* which is why the byline over the draft is the group's rather than the writer's.
|
||||
* Whoever types it is proposing words for the group to put its signature to, and
|
||||
* the words become a post -- attributed to the group and to nobody in particular --
|
||||
* only once a quorum has signed.
|
||||
*
|
||||
* The byline is the whole of the design here. Every other composer in this app puts
|
||||
* the author's name over the draft because the author is the person typing; getting
|
||||
* that wrong on this screen would have a member writing what looks like their own
|
||||
* note and finding the group had said it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddGroupPostScreen(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
|
||||
) {
|
||||
val addGroupPostViewModel: AddGroupPostViewModel = viewModel(
|
||||
factory = AddGroupPostViewModel.factory(
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddGroupPostUIState = initialAddGroupPostUIState,
|
||||
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_post)
|
||||
val nothingToSay = stringResource(Res.string.a_post_needs_something_to_say)
|
||||
|
||||
ScreenStateTransition(addGroupPostViewModel.addGroupPostUIState) { uiState ->
|
||||
when (val addGroupPostUIState = uiState) {
|
||||
is AddGroupPostUIState.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 = addGroupPostUIState.message, onRetry = null)
|
||||
}
|
||||
|
||||
is AddGroupPostUIState.Loaded -> {
|
||||
val noteFieldState = rememberTextFieldState()
|
||||
val canSign = addGroupPostUIState.canSign
|
||||
|
||||
// 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 typed.
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
modifier = Modifier.imePadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(Res.string.add_post)) }
|
||||
)
|
||||
},
|
||||
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
|
||||
|
||||
addGroupPostViewModel.proposePost(
|
||||
localChatRoom = addGroupPostUIState.localChatRoom,
|
||||
noteField = noteFieldState,
|
||||
onSuccess = { sessionId ->
|
||||
// Onto the session rather than back
|
||||
// to the group. Nothing has been
|
||||
// posted yet -- the post appears
|
||||
// when a quorum signs -- so landing
|
||||
// on the list it is not in would
|
||||
// read as a failure.
|
||||
onNavigateToRouteAndPopUpInclusive.invoke(
|
||||
FrostSigningRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
sessionId = sessionId
|
||||
)
|
||||
)
|
||||
},
|
||||
// Both stay on the draft. One is an
|
||||
// empty note and one is a failure to
|
||||
// propose, and neither is a reason to
|
||||
// throw away what was written.
|
||||
onEmpty = { notify(nothingToSay) },
|
||||
onFailure = { notify(couldNotPropose) }
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Draw, contentDescription = "Propose post")
|
||||
Text(stringResource(Res.string.propose_post))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { 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 a word is typed.
|
||||
// It is the group's, and it is the one thing about this
|
||||
// screen a member could reasonably get wrong.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ProfileAvatar(
|
||||
size = MaterialTheme.spacing.space500,
|
||||
publicKey = chatRoomId,
|
||||
picture = addGroupPostUIState.groupNostrProfile?.picture(),
|
||||
name = addGroupPostUIState.groupNostrProfile?.name()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
|
||||
|
||||
Text(
|
||||
text = addGroupPostUIState.groupNostrProfile?.name()
|
||||
?: addGroupPostUIState.localChatRoom.chatRoom.subject
|
||||
?: chatRoomId.take(16),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.the_group_signs_a_post_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_a_post
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f)
|
||||
.background(BottomAppBarDefaults.containerColor),
|
||||
state = noteFieldState,
|
||||
// The draft grows into the screen rather than scrolling
|
||||
// inside three lines: a post is the one thing here
|
||||
// somebody writes at length, and a composer that hides
|
||||
// what was written two sentences ago is a composer
|
||||
// nobody proofreads in.
|
||||
lineLimits = TextFieldLineLimits.MultiLine(),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent
|
||||
),
|
||||
placeholder = {
|
||||
Text(stringResource(Res.string.what_should_the_group_say))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddGroupPostUIState.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.add_post),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
LoadingDataIndicator(fillScreen = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (initialAddGroupPostUIState == AddGroupPostUIState.Loading) {
|
||||
addGroupPostViewModel.initiateAddGroupPost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConformancePreviews
|
||||
@Composable
|
||||
private fun AddGroupPostScreenPreview() {
|
||||
MantraTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AddGroupPostScreen(
|
||||
activeUserPublicKey = "",
|
||||
chatRoomId = "publicKey",
|
||||
relayHint = null,
|
||||
initialAddGroupPostUIState = AddGroupPostUIState.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 composer 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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package press.mantra.compose.ui.composable
|
||||
|
||||
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
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.Badge
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Draw
|
||||
import androidx.compose.material.icons.filled.LibraryBooks
|
||||
@@ -71,6 +73,7 @@ import press.mantra.compose.repository.NostrRepository
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
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.EditGroupNostrProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
@@ -94,6 +97,11 @@ import press.mantra.compose.ui.theme.spacing
|
||||
import mantra.composeapp.generated.resources.run_by_members
|
||||
import mantra.composeapp.generated.resources.nostr_profile
|
||||
import mantra.composeapp.generated.resources.relays
|
||||
import mantra.composeapp.generated.resources.posts
|
||||
import mantra.composeapp.generated.resources.add_post
|
||||
import mantra.composeapp.generated.resources.a_reply
|
||||
import mantra.composeapp.generated.resources.posted_on
|
||||
import mantra.composeapp.generated.resources.this_group_has_not_posted_anything_yet
|
||||
import mantra.composeapp.generated.resources.edit_relays
|
||||
import mantra.composeapp.generated.resources.not_set_yet
|
||||
import mantra.composeapp.generated.resources.no_relays_in_this_list
|
||||
@@ -117,6 +125,7 @@ import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRout
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
|
||||
import press.mantra.compose.managers.SubgroupManager
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
import press.mantra.compose.nostr.GroupRelaySet
|
||||
import mantra.composeapp.generated.resources.Res
|
||||
@@ -448,6 +457,70 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Under the relay lists, because these are what the
|
||||
// lists are *for*: the profile says who the group
|
||||
// is, the relays say where to find it, and these are
|
||||
// what a reader would find there. Still above the
|
||||
// library, which is the group's work rather than its
|
||||
// voice.
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.posts),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (chatRoomDetailUIState.groupPosts.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.this_group_has_not_posted_anything_yet
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
items = chatRoomDetailUIState.groupPosts,
|
||||
key = { post -> post.signedEvent.id }
|
||||
) { post ->
|
||||
GroupPostCard(
|
||||
post = post,
|
||||
publicKey = chatRoomId,
|
||||
groupNostrProfile =
|
||||
chatRoomDetailUIState.groupNostrProfile
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (chatRoomDetailUIState.canEditNostrProfile) {
|
||||
item {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onNavigateToRoute.invoke(
|
||||
AddGroupPostRoute(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Campaign,
|
||||
contentDescription = Decorative
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.width(
|
||||
MaterialTheme.spacing.space125
|
||||
)
|
||||
)
|
||||
|
||||
Text(stringResource(Res.string.add_post))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
@@ -1324,6 +1397,78 @@ private fun GroupRelaySet.summaryLabel() = when (this) {
|
||||
GroupRelaySet.Blocked -> Res.string.relay_set_blocked
|
||||
}
|
||||
|
||||
/**
|
||||
* One thing the group said, as the group signed it.
|
||||
*
|
||||
* The whole note rather than a preview. A post is short by construction -- kind:1
|
||||
* with no long-form arm behind it -- and truncating the one kind of content on this
|
||||
* screen that exists to be *read* would mean opening something to see a paragraph.
|
||||
*
|
||||
* **The byline is the group's**, taken from its profile where it has one and from
|
||||
* the room's own id where it has not. It is worth spelling out because a post is
|
||||
* the one thing here a reader might otherwise attribute to whichever member
|
||||
* proposed it -- and the point of signing it as the group is that they cannot.
|
||||
*
|
||||
* A reply is marked as one. Nothing in this app writes replies, so the mark is for
|
||||
* an event that arrived some other way; drawing one as an announcement would put
|
||||
* half a conversation on a group's page with no sign that it was half.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupPostCard(
|
||||
post: GroupPost,
|
||||
publicKey: HexKey,
|
||||
groupNostrProfile: GroupNostrProfile?
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProfileAvatar(
|
||||
size = MaterialTheme.spacing.space400,
|
||||
publicKey = publicKey,
|
||||
picture = groupNostrProfile?.picture(),
|
||||
name = groupNostrProfile?.name()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(MaterialTheme.spacing.itemGap))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = groupNostrProfile?.name() ?: publicKey.take(16).inComparableGroups(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.posted_on,
|
||||
post.postedAt.toFormattedTimeAndDateString()
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
if (!post.isNewThread()) {
|
||||
Text(
|
||||
text = stringResource(Res.string.a_reply),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = post.content,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One subgroup, as the parent's record and this device's rooms together have it.
|
||||
*
|
||||
|
||||
@@ -41,6 +41,7 @@ import press.mantra.compose.ui.composable.DkgRitualScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupNostrProfileScreen
|
||||
import press.mantra.compose.ui.composable.AddGroupPostScreen
|
||||
import press.mantra.compose.ui.composable.EditGroupRelaysScreen
|
||||
import press.mantra.compose.ui.composable.HomeScreen
|
||||
import press.mantra.compose.ui.composable.ImplementationPendingScreen
|
||||
@@ -125,6 +126,7 @@ import press.mantra.compose.ui.composable.AddArtifactScreen
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupNostrProfileRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.AddGroupPostRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.EditGroupRelaysRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute
|
||||
@@ -1012,6 +1014,26 @@ fun MantraNavHost(
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<AddGroupPostRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<AddGroupPostRoute>()
|
||||
|
||||
AddGroupPostScreen(
|
||||
activeUserPublicKey = route.activeUserPublicKey,
|
||||
chatRoomId = route.chatRoomId,
|
||||
relayHint = route.relayHint,
|
||||
chatRepository = databaseChatRepository,
|
||||
frostSigningRepository = databaseFrostSigningRepository,
|
||||
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
|
||||
// Replace the composer so back returns to the group rather
|
||||
// than to a draft whose proposal has already gone out.
|
||||
navController.navigate(route = signingRoute) {
|
||||
popUpTo<AddGroupPostRoute> {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<FrostSigningRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<FrostSigningRoute>()
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package press.mantra.compose.ui.composable.navigation.routes
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AddGroupPostRoute(
|
||||
val activeUserPublicKey: String,
|
||||
val chatRoomId: String, // TODO: have this as a publicKey
|
||||
val relayHint: String?
|
||||
): Route()
|
||||
@@ -0,0 +1,154 @@
|
||||
package press.mantra.compose.ui.view.model
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.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.GroupPost
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.ui.view.state.AddGroupPostUIState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Asks the group to say something publicly, in its own name.
|
||||
*
|
||||
* The post is not written here and does not exist yet. What goes out is a proposal
|
||||
* to sign one, and the post appears -- on every member's device at once, authored by
|
||||
* the room's own key rather than by whoever typed it -- when enough members have
|
||||
* signed. That author is the room's id: signing runs at the path the room was
|
||||
* derived at, so a post says which group made it simply by being signed.
|
||||
*
|
||||
* That is the difference from a message in the room. A message is a member speaking
|
||||
* where the group can hear; this is the group speaking where anyone can, and it
|
||||
* takes a quorum to say.
|
||||
*/
|
||||
class AddGroupPostViewModel(
|
||||
val chatRoomId: String,
|
||||
val activeUserPublicKey: HexKey,
|
||||
val relayHint: String?,
|
||||
initialAddGroupPostUIState: AddGroupPostUIState,
|
||||
val chatRepository: ChatRepository,
|
||||
val frostSigningRepository: FrostSigningRepository,
|
||||
): ViewModel() {
|
||||
|
||||
var addGroupPostUIState: AddGroupPostUIState by mutableStateOf(initialAddGroupPostUIState)
|
||||
private set
|
||||
|
||||
private val logger = Logger.withTag(TAG)
|
||||
|
||||
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
|
||||
|
||||
fun initiateAddGroupPost() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
|
||||
|
||||
addGroupPostUIState = if (localChatRoom == null) {
|
||||
AddGroupPostUIState.Error("Couldn't find the chat room")
|
||||
} else {
|
||||
AddGroupPostUIState.Loaded(
|
||||
localChatRoom = localChatRoom,
|
||||
groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
|
||||
// A NIP-17 room has no key of its own to sign as, so it has no
|
||||
// identity to post under -- the same condition the group detail
|
||||
// screen gates the entry point on.
|
||||
canSign = localChatRoom.chatRoom.mlsGroupState != null &&
|
||||
frostSigningRepository.canSign(chatRoomId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a session over one kind:1 authored by the group.
|
||||
*
|
||||
* Refuses a blank draft rather than proposing it: a quorum signing an empty note
|
||||
* would put a post on the record that renders as nothing and cannot be told from
|
||||
* a broken one -- and kind:1 is not replaceable, so there is no un-saying it.
|
||||
* `GroupPost.newestFirstAmong` drops blanks on the way back out too, which is
|
||||
* the second line of the same defence.
|
||||
*
|
||||
* The draft is not cleared on success. This screen is replaced by the signing
|
||||
* session, and on the failure path the words are the thing worth keeping.
|
||||
*/
|
||||
fun proposePost(
|
||||
localChatRoom: LocalChatRoom,
|
||||
noteField: TextFieldState,
|
||||
onSuccess: (sessionId: String) -> Unit,
|
||||
onEmpty: () -> Unit,
|
||||
onFailure: () -> Unit
|
||||
) {
|
||||
// Guard against double submits. Posts accumulate rather than replace, so a
|
||||
// second session over the same words is a second post on the record forever.
|
||||
if (isActionPending.value) return
|
||||
|
||||
val note = noteField.text.toString().trim()
|
||||
if (note.isBlank()) {
|
||||
onEmpty.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
isActionPending.value = true
|
||||
|
||||
val template = GroupPost.template(note)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val session = runCatching {
|
||||
frostSigningRepository.proposeSigning(
|
||||
localChatRoom = localChatRoom,
|
||||
userPublicKey = activeUserPublicKey,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
logger.e("Failed to propose a post 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 = "AddGroupPostViewModel"
|
||||
|
||||
fun factory(
|
||||
activeUserPublicKey: HexKey,
|
||||
chatRoomId: String,
|
||||
relayHint: String?,
|
||||
initialAddGroupPostUIState: AddGroupPostUIState = AddGroupPostUIState.Loading,
|
||||
chatRepository: ChatRepository,
|
||||
frostSigningRepository: FrostSigningRepository
|
||||
): ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
AddGroupPostViewModel(
|
||||
activeUserPublicKey = activeUserPublicKey,
|
||||
chatRoomId = chatRoomId,
|
||||
relayHint = relayHint,
|
||||
initialAddGroupPostUIState = initialAddGroupPostUIState,
|
||||
chatRepository = chatRepository,
|
||||
frostSigningRepository = frostSigningRepository
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ class ChatRoomDetailViewModel(
|
||||
parentChatRoomId = chatRepository.parentOf(chatRoomId),
|
||||
groupNostrProfile = chatRepository.groupNostrProfile(chatRoomId),
|
||||
groupRelayLists = chatRepository.groupRelayLists(chatRoomId),
|
||||
groupPosts = chatRepository.groupPosts(chatRoomId),
|
||||
canEditNostrProfile = canSign,
|
||||
canAddSubgroup = canSign,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package press.mantra.compose.ui.view.state
|
||||
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
|
||||
sealed interface AddGroupPostUIState {
|
||||
data class Loaded(
|
||||
val localChatRoom: LocalChatRoom,
|
||||
/**
|
||||
* Who the group says it is, for the byline over the draft.
|
||||
*
|
||||
* A post is signed by the group and read as the group, so the composer
|
||||
* shows whose voice is being written in -- which is not the member typing.
|
||||
* Null in a group that has not described itself, and the byline falls back
|
||||
* to the room.
|
||||
*/
|
||||
val groupNostrProfile: GroupNostrProfile? = null,
|
||||
/**
|
||||
* Whether this device holds a share of the group's key.
|
||||
*
|
||||
* False leaves the draft writable and the button inert, for the same reason
|
||||
* the other two editors do: reading and composing cost nothing, and only a
|
||||
* share-holder can open the session that would sign it.
|
||||
*/
|
||||
val canSign: Boolean = false,
|
||||
): AddGroupPostUIState
|
||||
|
||||
data class Error(
|
||||
val message: String
|
||||
): AddGroupPostUIState
|
||||
|
||||
data object Loading: AddGroupPostUIState
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import press.mantra.compose.database.model.MantraArtifact
|
||||
import press.mantra.compose.database.model.MantraDialect
|
||||
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.nostr.GroupNostrProfile
|
||||
import press.mantra.compose.nostr.GroupPost
|
||||
import press.mantra.compose.nostr.GroupRelayList
|
||||
|
||||
sealed interface ChatRoomDetailUIState {
|
||||
@@ -74,6 +75,15 @@ sealed interface ChatRoomDetailUIState {
|
||||
* four entries, some of them unsigned; see `ChatRepository.groupRelayLists`.
|
||||
*/
|
||||
val groupRelayLists: List<GroupRelayList> = emptyList(),
|
||||
/**
|
||||
* What the group has said publicly, as itself, newest first.
|
||||
*
|
||||
* Under the relay lists because it is the thing they are *for*: the profile
|
||||
* says who the group is, the relay lists say where to find it, and these
|
||||
* are what a reader would find there. Empty in a group that has said
|
||||
* nothing, which is every group until somebody proposes one.
|
||||
*/
|
||||
val groupPosts: List<GroupPost> = emptyList(),
|
||||
/**
|
||||
* Whether this device could sign a profile for the group.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user