feat: sign a dialect into existence instead of submitting one

Adding a dialect no longer creates one. It opens a signing session over a
DialectEvent, and the dialect appears -- on every member's device at
once, authored by the group's shared key rather than by whoever typed it
-- when enough members have signed.

That is the difference between the two envelopes. A submission says "I am
putting this in front of the group"; the group's only recourse afterwards
is social, and the row records the submitter as its author. A signature
is the group saying it, it takes a quorum to say, and the author on the
row is the group's key. For something as load-bearing as the set of
dialects a group translates into, the second is the honest one.

**Where the signed event becomes a row.** Every device has the event and
the signature once the session completes, so each applies the result
itself rather than waiting to be sent something it can already build --
the same reasoning the transcript lines are written on. Nothing goes on
the wire for it, and nothing could: the outbound pipeline re-authors
rumors as their sender, so a group-signed event pushed through it would
come out stripped of the signature and attributed to whoever sent it.

Applying reuses the inbound path's dispatch rather than repeating it.
applyInnerEvent takes plain ids now instead of a GroupEvent, and both are
null here, because there is no group event and no inner event behind a
row a device derived for itself. A failure there is logged and the
session still completes: the signature is made and valid, and failing the
session would tell the group to abandon something that succeeded.

**The screen.** One, not three. A ceremony asks three different questions
so it gets three approval screens; signing asks one -- sign this or do
not -- so a single screen has to carry the whole case: what is being
signed, who else has agreed, and what the group is still waiting on. The
event is shown as the thing it is, a dialect with its name and country
and language, because a member deciding whether to sign is deciding about
a dialect and "kind 30304" answers a question nobody asked. Anything
unrecognised falls back to the raw kind, which is better than describing
it wrongly.

The member ladder names people rather than counting them, for the same
reason the ceremony's does: "1 of 2" does not tell anyone whose door to
knock on. It stays useful after the decision, since a member who has
already signed is exactly who needs to see who has not.

**Getting there.** Signing lines render in the transcript as system
notices like ritual lines -- nobody said them either -- but they lead to
the session rather than to the key. A chat row carries no session id and
adding a column to the table every message uses would be a poor trade for
a lookup, so FrostSigningRoute takes a nullable id and the screen
resolves the room's live session. Approving is recorded as answered by
the nonce line rather than the partial signature: agreeing is agreeing to
take part, and the coordinator may then pick a quorum without you, which
should not leave you looking like you never replied.

**Proposing needs a key.** The FAB is disabled, and says why, when the
room has none -- proposeSigning throws there, and it is not reachable
outside the #admins room in the first place. AddDialectViewModel drops
MantraRepository, which it no longer uses for anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 22:09:47 +02:00
parent 63c1879ace
commit 3e4166f13d
15 changed files with 1048 additions and 70 deletions

View File

@@ -27,6 +27,9 @@ interface FrostSigningSessionDao {
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC LIMIT 1")
suspend fun getLatestSessionForChatRoom(chatRoomId: String): FrostSigningSession?
@Query("SELECT * FROM FrostSigningSession WHERE chatRoomId = :chatRoomId ORDER BY createdAt DESC")
suspend fun getSessionsForChatRoom(chatRoomId: String): List<FrostSigningSession>
@Upsert
suspend fun upsert(frostSigningSession: FrostSigningSession)

View File

@@ -198,6 +198,20 @@ data class ChatMessage(
/** Addressed to the reader rather than said by anyone -- see [DKG_REQUEST_TYPES]. */
const val TYPE_FROST_APPROVAL_NEEDED = "frostApprovalNeeded"
/**
* Answering the request publishes this device's nonce, not its signature:
* approving is agreeing to take part, and the coordinator may then pick a
* quorum that does not include this member. Keying the answer on the
* partial signature would leave a member who agreed, and was not needed,
* looking like they never replied.
*/
val FROST_REQUEST_FULFILMENTS = mapOf(
TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE,
)
/** The signing lines that ask rather than report. */
val FROST_REQUEST_TYPES = setOf(TYPE_FROST_APPROVAL_NEEDED)
/** Every signing line, for rendering them as system lines rather than bubbles. */
val FROST_TYPES = setOf(
TYPE_FROST_STARTED,
@@ -297,12 +311,12 @@ data class ChatMessage(
} else {
applyInnerEvent(
database = database,
activeKeyPair = activeKeyPair,
groupEvent = groupEvent,
groupId = groupEventResult.groupId,
event = payload ?: event,
marmotGroupEventId = groupEvent.id,
marmotInnerEventId = event.id,
senderPublicKey = event.pubKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
createdAt = Instant.fromEpochSeconds(event.createdAt),
)
}
@@ -401,14 +415,14 @@ data class ChatMessage(
* from [event], so the chat line says who added it and the row says who
* wrote it.
*/
private suspend fun applyInnerEvent(
internal suspend fun applyInnerEvent(
database: MantraDatabase,
activeKeyPair: KeyPair,
groupEvent: GroupEvent,
groupId: String,
event: Event,
marmotInnerEventId: HexKey,
marmotGroupEventId: HexKey?,
marmotInnerEventId: HexKey?,
senderPublicKey: HexKey,
isUserMessage: Boolean,
createdAt: Instant,
): ChatMessage? {
return when (event.kind) {
@@ -416,10 +430,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "message",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.content, // TODO: Figure out what to do here...
@@ -439,17 +453,17 @@ data class ChatMessage(
)?.let { mantraArtifact ->
database.mantraArtifactDao().upsert(
mantraArtifact.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifact",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifact.name} to artifacts"
@@ -470,17 +484,17 @@ data class ChatMessage(
)?.let { mantraArtifactVersion ->
database.mantraArtifactVersionDao().upsert(
mantraArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "artifactVersion",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraArtifactVersion.versionLabel} to artifact versions" // TODO: Use artifact name...
@@ -501,17 +515,17 @@ data class ChatMessage(
)?.let { mantraChapter ->
database.mantraChapterDao().upsert(
mantraChapter.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "chapter",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraChapter.name} to chapters" // TODO: Use artifact name...
@@ -533,7 +547,7 @@ data class ChatMessage(
)?.let { mantraChunk ->
database.mantraChunkDao().upsert(
mantraChunk.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -555,7 +569,7 @@ data class ChatMessage(
)?.let { mantraDialect ->
database.mantraDialectDao().upsert(
mantraDialect.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -563,10 +577,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "dialect",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraDialect.name} to dialects" // TODO: Use artifact name...
@@ -587,17 +601,17 @@ data class ChatMessage(
)?.let { mantraTranslationArtifactVersion ->
database.mantraTranslationArtifactVersionDao().upsert(
mantraTranslationArtifactVersion.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translationArtifactVersion",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslationArtifactVersion.name} to translation artifact versions" // TODO: Use artifact name...
@@ -622,7 +636,7 @@ data class ChatMessage(
)?.let { mantraTranslationChapter ->
database.mantraTranslationChapterDao().upsert(
mantraTranslationChapter.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
// TODO: translation chapter might be too noisy for chat updates
@@ -643,7 +657,7 @@ data class ChatMessage(
)?.let { mantraTranslationChunk ->
database.mantraTranslationChunkDao().upsert(
mantraTranslationChunk.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
@@ -669,16 +683,16 @@ data class ChatMessage(
)?.let { mantraTranslation ->
database.mantraTranslationDao().upsert(
mantraTranslation.copy(
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
)
)
ChatMessage(
giftWrapPayloadId = null,
messageType = "translation",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = "Added ${mantraTranslation.text} to translations" // TODO: Reference original text
@@ -695,10 +709,10 @@ data class ChatMessage(
ChatMessage(
giftWrapPayloadId = null,
messageType = "unsupported",
marmotGroupEventId = groupEvent.id,
marmotGroupEventId = marmotGroupEventId,
marmotInnerEventId = marmotInnerEventId,
senderPublicKey = senderPublicKey,
isUserMessage = activeKeyPair.pubKey.toHex() == groupEvent.pubKey,
isUserMessage = isUserMessage,
chatRoomId = groupId,
createdAt = createdAt,
content = event.toJson(),

View File

@@ -0,0 +1,101 @@
package press.mantra.compose.database.repository
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.repository.FrostSigningRepository
class DatabaseFrostSigningRepository(
private val database: MantraDatabase,
private val scope: CoroutineScope
): FrostSigningRepository {
private val logger = Logger.withTag(TAG)
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> =
database.frostSigningSessionDao().observeSessionById(sessionId)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId)
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
database.frostSigningSessionDao().observeMessages(sessionId)
override suspend fun getSessionById(sessionId: String): FrostSigningSession? =
database.frostSigningSessionDao().getSessionById(sessionId)
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? {
val sessions = database.frostSigningSessionDao().getSessionsForChatRoom(chatRoomId)
return sessions.firstOrNull {
it.stage != FrostSigningStage.COMPLETE && it.stage != FrostSigningStage.FAILED
} ?: sessions.firstOrNull()
}
override suspend fun canSign(chatRoomId: String): Boolean =
FrostSigningManager.canSign(database, chatRoomId)
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = try {
FrostSigningManager.proposeSigning(
database = database,
localChatRoom = localChatRoom,
userPublicKey = userPublicKey,
kind = kind,
tags = tags,
content = content
)
} catch (e: Throwable) {
// Proposing throws when the group has no key or this device was not in the
// ceremony. Both are states the UI is supposed to have checked for, so this
// is a null the caller reports rather than a crash.
logger.e("Error proposing a signature in ${localChatRoom.chatRoom.id}", e)
null
}
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.approve(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
// The session fails itself and tells the group; swallowing here keeps a
// protocol fault from taking the screen down with it.
logger.e("Error approving signing session $sessionId", e)
}
}
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) {
try {
FrostSigningManager.decline(
database = database,
localChatRoom = localChatRoom,
sessionId = sessionId
)
} catch (e: Throwable) {
logger.e("Error declining signing session $sessionId", e)
}
}
override fun signedEvent(session: FrostSigningSession): Event? =
FrostSigningManager.signedEvent(session)
companion object {
private const val TAG = "DatabaseFrostSigningRepository"
}
}

View File

@@ -596,6 +596,16 @@ object FrostSigningManager {
}
update(database, session) { it.copy(stage = FrostSigningStage.COMPLETE) }
// A signature exists to be used. Every device has the event and the
// signature by now, so each applies the result itself rather than
// waiting to be sent something it can already build -- the same
// reasoning the transcript lines are written on. Nothing goes on the
// wire: a signed event authored by the threshold key cannot travel as
// an inner event anyway, because the outbound pipeline re-authors
// rumors as their sender and would strip the group's signature off.
applySignedEvent(database, session, signedEvent)
announce(
database = database,
session = session,
@@ -622,6 +632,39 @@ object FrostSigningManager {
}
}
/**
* Turns the signed event into whatever it is: a dialect, an artifact, a
* chapter.
*
* Reuses the inbound path's dispatch rather than repeating it, with no group
* event and no inner event behind the row -- there is neither, and both
* columns are nullable for exactly this kind of locally-derived record.
*
* A failure here is not the session's: the signature is made and valid, and
* saying otherwise would tell the group to abandon a ceremony that
* succeeded. It is logged and the session still completes.
*/
private suspend fun applySignedEvent(
database: MantraDatabase,
session: FrostSigningSession,
signedEvent: Event
) {
try {
ChatMessage.applyInnerEvent(
database = database,
groupId = session.chatRoomId,
event = signedEvent,
marmotGroupEventId = null,
marmotInnerEventId = null,
senderPublicKey = session.coordinatorPublicKey,
isUserMessage = session.isCoordinator(),
createdAt = Clock.System.now()
)?.let { database.chatMessageDao().upsert(it) }
} catch (e: Throwable) {
logger.e("Signed ${signedEvent.id} but could not apply it locally", e)
}
}
/**
* The event this session produces, with the signature on it.
*

View File

@@ -0,0 +1,94 @@
package press.mantra.compose.repository
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import press.mantra.compose.database.model.FrostSignerMessage
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
/**
* Reads, opens and answers FROST signing sessions.
*
* Advancing one is the inbound path's job, with one exception: a session waiting
* on this device's owner does not advance until [approve] is called, because it
* will not sign on their behalf until they say so.
*/
interface FrostSigningRepository {
fun observeSessionById(sessionId: String): Flow<FrostSigningSession?>
/** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */
fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>>
fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>>
suspend fun getSessionById(sessionId: String): FrostSigningSession?
/**
* The session a room is currently running, or its most recent one if none is.
*
* For callers that mean "the signing going on here" without holding an id --
* a transcript line, mostly. Prefers a live session because that is the one
* anybody tapping through wants to act on; a finished one is only what is
* left to show when there is nothing live.
*/
suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession?
/** Whether this room holds a key it can sign with, so the UI offers nothing that would fail. */
suspend fun canSign(chatRoomId: String): Boolean
/**
* Opens a session asking the group to sign an event with these fields. The
* author is the group's key, not the proposer's, and is filled in here.
*/
suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession?
/** Agrees to sign, letting the session publish this device's part and run on. */
suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String)
/** Refuses, and says so, since a t-of-n group can proceed without this member. */
suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String)
/** The finished event, or null while the session is still running. */
fun signedEvent(session: FrostSigningSession): Event?
companion object {
val NO_OP_FROST_SIGNING_REPOSITORY: FrostSigningRepository = object : FrostSigningRepository {
override fun observeSessionById(sessionId: String): Flow<FrostSigningSession?> = flowOf(null)
override fun observeSessionsForChatRoom(chatRoomId: String): Flow<List<FrostSigningSession>> =
flowOf(emptyList())
override fun observeMessages(sessionId: String): Flow<List<FrostSignerMessage>> =
flowOf(emptyList())
override suspend fun getSessionById(sessionId: String): FrostSigningSession? = null
override suspend fun liveSessionForChatRoom(chatRoomId: String): FrostSigningSession? = null
override suspend fun canSign(chatRoomId: String): Boolean = false
override suspend fun proposeSigning(
localChatRoom: LocalChatRoom,
userPublicKey: HexKey,
kind: Kind,
tags: Array<Array<String>>,
content: String
): FrostSigningSession? = null
override suspend fun approve(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override suspend fun decline(localChatRoom: LocalChatRoom, sessionId: String) = Unit
override fun signedEvent(session: FrostSigningSession): Event? = null
}
}
}

View File

@@ -16,9 +16,11 @@ import androidx.compose.material.icons.filled.Title
import androidx.compose.material.icons.filled.Translate
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
@@ -27,11 +29,14 @@ import androidx.compose.material3.Scaffold
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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -40,9 +45,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.MantraRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
@@ -59,7 +64,7 @@ fun AddDialectScreen(
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateToRouteAndPopUpInclusive: (Route) -> Unit,
onNavigateToRoute: (Route) -> Unit,
) {
@@ -71,7 +76,7 @@ fun AddDialectScreen(
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey,
mantraRepository = mantraRepository
frostSigningRepository = frostSigningRepository
)
)
@@ -94,6 +99,10 @@ fun AddDialectScreen(
val countryFieldState = rememberTextFieldState()
val languageFieldState = rememberTextFieldState()
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
topBar = {
TopAppBar(
@@ -110,20 +119,41 @@ fun AddDialectScreen(
actions = {},
floatingActionButton = {
ExtendedFloatingActionButton(
modifier = if (addDialectUIState.canSign) {
Modifier
} else {
// Looking unavailable is not being unavailable.
Modifier.semantics { disabled() }
},
containerColor = if (addDialectUIState.canSign) {
FloatingActionButtonDefaults.containerColor
} else {
buttonColors.disabledContainerColor
},
contentColor = if (addDialectUIState.canSign) {
contentColorFor(FloatingActionButtonDefaults.containerColor)
} else {
buttonColors.disabledContentColor
},
onClick = {
if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton
addDialectViewModel.addDialect(
localChatRoom = addDialectUIState.localChatRoom,
nameField = nameFieldState,
countryField = countryFieldState,
languageField = languageFieldState,
onSuccess = {
// Back to the group, reloaded so the new
// dialect shows up in the list.
onSuccess = { sessionId ->
// Onto the session rather than back to
// the group. Nothing has been created
// yet -- the dialect appears when enough
// members sign -- so landing on the list
// it is not in would read as a failure.
onNavigateToRouteAndPopUpInclusive.invoke(
ChatRoomDetailRoute(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint
sessionId = sessionId
)
)
},
@@ -137,9 +167,9 @@ fun AddDialectScreen(
) {
Icon(
Icons.Default.Add,
contentDescription = "Add dialect"
contentDescription = "Propose dialect"
)
Text("Add Dialect")
Text("Propose Dialect")
}
}
)
@@ -155,6 +185,15 @@ fun AddDialectScreen(
) {
Text("Add a dialect the group can translate into")
if (!addDialectUIState.canSign) {
Text(
text = "This group has no shared key, so it cannot sign a " +
"dialect into existence. Run a shared key ceremony first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth()
.background(BottomAppBarDefaults.containerColor),
@@ -300,7 +339,7 @@ private fun AddDialectScreenPreview() {
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
mantraRepository = MantraRepository.NO_OP_MANTRA_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateToRouteAndPopUpInclusive = {},
onNavigateToRoute = {}
)

View File

@@ -43,6 +43,7 @@ import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute
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.LoadingDataIndicator
import press.mantra.compose.ui.theme.TorchTheme
@@ -141,6 +142,16 @@ fun ChatRoomMessagingScreen(
chatRoomId = chatRoomId
)
)
},
onOpenSigning = {
// No session id: a chat row carries none, and the
// screen resolves the room's live one.
onNavigateToRoute.invoke(
FrostSigningRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId
)
)
}
)
}

View File

@@ -0,0 +1,373 @@
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.database.model.types.FrostSigningStage
import press.mantra.compose.nostr.nip30303.ArtifactEvent
import press.mantra.compose.nostr.nip30303.ChapterEvent
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar
import press.mantra.compose.ui.theme.TorchTheme
import press.mantra.compose.ui.view.model.FrostSigningViewModel
import press.mantra.compose.ui.view.state.FrostSigningUIState
/**
* One signing session, and the member's decision about it.
*
* A ceremony gets three approval screens because it asks three different
* questions. Signing asks one — sign this or do not — so there is one screen,
* and it has to carry the whole case for the answer: what is being signed, who
* else has agreed, and what the group is still waiting on.
*
* It stays useful after the decision. A session cannot finish until enough
* members take part, so a member who has already signed still needs to see
* whose door to knock on, and the ladder is the only place that says.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrostSigningScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
sessionId: String?,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
onNavigateBack: () -> Unit,
) {
val frostSigningViewModel: FrostSigningViewModel = viewModel(
factory = FrostSigningViewModel.factory(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
)
// Nothing loads the room or starts watching the session until this runs.
LaunchedEffect(true) {
if (initialFrostSigningUIState == FrostSigningUIState.Loading) {
frostSigningViewModel.initiate()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Sign with the group's key",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
when (val state = frostSigningViewModel.frostSigningUIState) {
is FrostSigningUIState.Loading -> Loading(padding)
is FrostSigningUIState.Error -> Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
Text(text = state.message, textAlign = TextAlign.Center)
}
is FrostSigningUIState.Loaded -> {
// Loaded means the room is loaded, not the session: initiate() sets
// this state and only then starts collecting, so the first emission
// always has a null session. Reading that as "no such session" would
// flash an error on the way in.
val session = state.session ?: return@Scaffold Loading(padding)
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(15.dp)
) {
WhatIsBeingSigned(frostSigningViewModel.proposedEvent(session))
HorizontalDivider()
Text(
text = statusOf(session),
style = MaterialTheme.typography.bodyMedium
)
session.failureReason?.let { reason ->
Text(
text = reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider()
Text(
text = "Members",
style = MaterialTheme.typography.labelMedium
)
// Named rather than counted, for the same reason the ceremony's
// ladder names people: "1 of 2" does not tell anyone whose door
// to knock on, and a session stalls until somebody knocks.
state.localChatRoom.localParticipants
.distinctBy { it.participant.participantPublicKey }
.forEach { localParticipant ->
val member = localParticipant.participant.participantPublicKey
Card {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = member,
profile = localParticipant.profile
)
},
trailingContent = {
when {
member in state.signed -> Icon(
Icons.Default.CheckCircle,
contentDescription = "Signed",
tint = MaterialTheme.colorScheme.primary
)
member in state.offeredNonce -> Icon(
Icons.Default.HourglassEmpty,
contentDescription = "Ready to sign"
)
else -> Icon(
Icons.Default.RadioButtonUnchecked,
contentDescription = "Not yet",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
headlineContent = {
Text(
text = localParticipant.profile
?.humanReadableNameOrPubkey()
?: member
)
},
supportingContent = {
Text(
text = when {
member in state.signed -> "Signed their part"
member in state.offeredNonce -> "Ready to sign"
else -> "Has not taken part yet"
}
)
}
)
}
}
if (session.signApprovedAt == null &&
session.stage != FrostSigningStage.COMPLETE &&
session.stage != FrostSigningStage.FAILED
) {
HorizontalDivider()
Text(
text = "Nothing has been published from this device yet. Signing " +
"puts your share behind this event; it cannot be taken back.",
style = MaterialTheme.typography.bodySmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.approve(onNavigateBack) }
) {
Icon(Icons.Default.Draw, contentDescription = null)
Spacer(modifier = Modifier.width(10.dp))
Text("Sign")
}
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = !frostSigningViewModel.isActionPending.value,
onClick = { frostSigningViewModel.decline(onNavigateBack) }
) {
Text("Don't sign")
}
}
}
}
}
}
}
}
@Composable
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
Column(
modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(50.dp))
CircularProgressIndicator()
}
}
/**
* What the group is being asked to put its name to.
*
* Shown as the thing rather than as an event: a member deciding whether to sign
* is deciding about a dialect or an artifact, and "kind 30304" answers a
* question nobody asked. The raw kind stays for anything not recognised, since
* refusing to describe an event is better than describing it wrongly.
*/
@Composable
private fun WhatIsBeingSigned(event: Event?) {
if (event == null) {
Text(
text = "This session's event could not be read, so there is nothing to check " +
"before signing. Don't sign it.",
color = MaterialTheme.colorScheme.error
)
return
}
val (label, detail) = when (event.kind) {
DialectEvent.KIND -> "New dialect" to DialectEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).let { dialect ->
listOfNotNull(dialect.name(), dialect.country(), dialect.language())
.joinToString(" · ")
}
ArtifactEvent.KIND -> "New artifact" to event.content
ChapterEvent.KIND -> "New chapter" to ChapterEvent(
event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig
).name().orEmpty()
else -> "Event of kind ${event.kind}" to event.content
}
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = detail, style = MaterialTheme.typography.titleMedium)
Text(
text = "Signed by the group, not by you. Once enough members sign, this is " +
"published under the group's shared key.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun statusOf(session: FrostSigningSession): String = when (session.stage) {
FrostSigningStage.COLLECTING_NONCES ->
"Waiting for ${session.threshold} of ${session.participantCount} members to take part."
FrostSigningStage.COLLECTING_PARTIAL_SIGNATURES ->
if (session.isSigner()) {
"You are one of the signers. Waiting on the rest of them."
} else {
"Enough members took part without needing you. Waiting on them to sign."
}
FrostSigningStage.COMPLETE -> "Signed."
FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again."
}
@Preview
@Composable
private fun FrostSigningScreenPreview() {
TorchTheme {
Surface(modifier = Modifier.fillMaxSize()) {
FrostSigningScreen(
activeUserPublicKey = "",
chatRoomId = "chatRoomId",
sessionId = "sessionId",
initialFrostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "chatRoomId",
userPublicKey = "",
subject = "Group (#admins)",
description = null,
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
)
)
),
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY,
onNavigateBack = {}
)
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.navigation.toRoute
import press.mantra.compose.MantraGlobal
import press.mantra.compose.database.repository.DatabaseChatRepository
import press.mantra.compose.database.repository.DatabaseDkgRepository
import press.mantra.compose.database.repository.DatabaseFrostSigningRepository
import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.database.repository.DatabaseSearchRepository
@@ -104,6 +105,7 @@ import press.mantra.compose.database.repository.DatabaseMantraRepository
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.FrostSigningRoute
import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute
import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute
import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute
@@ -114,6 +116,7 @@ import press.mantra.compose.ui.composable.navigation.routes.TranslationArtifactV
import press.mantra.compose.ui.composable.ArtifactDetailScreen
import press.mantra.compose.ui.composable.AddChapterScreen
import press.mantra.compose.ui.composable.AddDialectScreen
import press.mantra.compose.ui.composable.FrostSigningScreen
import press.mantra.compose.ui.composable.AddTranslationArtifactVersionScreen
import press.mantra.compose.ui.composable.ChapterDetailScreen
import press.mantra.compose.ui.composable.TranslateChunkScreen
@@ -164,6 +167,13 @@ fun MantraNavHost(
)
}
val databaseFrostSigningRepository = remember {
DatabaseFrostSigningRepository(
database = auxDatabaseManager.auxDatabase,
applicationIOScope
)
}
val databaseMarmotRepository = remember {
DatabaseMarmotRepository(
database = auxDatabaseManager.auxDatabase,
@@ -825,14 +835,12 @@ fun MantraNavHost(
relayHint = route.relayHint,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
mantraRepository = databaseMantraRepository,
onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute ->
// Replace both this add screen and the stale chat room detail
// beneath it so we land on a freshly-loaded detail screen.
navController.navigate(
route = chatRoomDetailRoute
) {
popUpTo<ChatRoomDetailRoute> {
frostSigningRepository = databaseFrostSigningRepository,
onNavigateToRouteAndPopUpInclusive = { signingRoute ->
// Replace this add screen so back returns to the group rather
// than to a form whose proposal has already gone out.
navController.navigate(route = signingRoute) {
popUpTo<AddDialectRoute> {
inclusive = true
}
}
@@ -844,6 +852,20 @@ fun MantraNavHost(
}
)
}
composable<FrostSigningRoute> { backStackEntry ->
val route = backStackEntry.toRoute<FrostSigningRoute>()
FrostSigningScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
sessionId = route.sessionId,
chatRepository = databaseChatRepository,
frostSigningRepository = databaseFrostSigningRepository,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable<ArtifactDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ArtifactDetailRoute>()

View File

@@ -0,0 +1,27 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/**
* One signing session.
*
* Carries a session id rather than only a room, because a group signs
* repeatedly and can have more than one session open at a time -- unlike a
* ceremony, where "the room's ritual" identifies it.
*/
@Serializable
data class FrostSigningRoute(
val activeUserPublicKey: String,
val chatRoomId: String,
/**
* Null when the caller does not know which session it means.
*
* A transcript line is the main case: chat rows carry no session, and adding
* a column for one feature to a table every message uses is a poor trade for
* a lookup the screen can do. It resolves to the room's live session, which
* is the one a line is talking about in every case but a group running two
* at once.
*/
val sessionId: String? = null
): Route()

View File

@@ -18,8 +18,9 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.MantraRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.nostr.nip30303.DialectEvent
import press.mantra.compose.ui.view.state.AddDialectUIState
class AddDialectViewModel(
@@ -29,7 +30,7 @@ class AddDialectViewModel(
initialAddDialectUIState: AddDialectUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val mantraRepository: MantraRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var addDialectUIState: AddDialectUIState by mutableStateOf(initialAddDialectUIState)
@@ -39,6 +40,14 @@ class AddDialectViewModel(
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Whether this group holds a key it can sign with.
*
* Read before the form is offered: a group without one cannot make a dialect
* this way at all, and a button that always fails is worse than no button.
*/
suspend fun canSign(): Boolean = frostSigningRepository.canSign(chatRoomId)
fun initiateAddDialect() {
logger.d("compressed (most likely chat room): $chatRoomId")
viewModelScope.launch(Dispatchers.IO) {
@@ -49,17 +58,30 @@ class AddDialectViewModel(
} else {
AddDialectUIState.Loaded(
localChatRoom = localChatRoom,
canSign = frostSigningRepository.canSign(chatRoomId),
)
}
}
}
/**
* Asks the group to sign a new dialect into existence.
*
* The dialect is not created here and does not exist yet. What goes out is a
* proposal to sign it, and the dialect appears -- on every member's device at
* once, authored by the group's shared key rather than by whoever typed it --
* when enough members have signed.
*
* That is the difference from submitting one. A submission says "I am putting
* this in front of the group" and the group's only recourse afterwards is
* social. A signature is the group saying it, and it takes a quorum to say.
*/
fun addDialect(
localChatRoom: LocalChatRoom,
nameField: TextFieldState,
countryField: TextFieldState,
languageField: TextFieldState,
onSuccess: (dialectId: String) -> Unit,
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
val name = nameField.text.toString()
@@ -76,25 +98,31 @@ class AddDialectViewModel(
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
val dialect = runCatching {
mantraRepository.addDialect(
val dialectEventTemplate = DialectEvent.build(
name = name,
country = country,
language = language,
)
val session = runCatching {
frostSigningRepository.proposeSigning(
localChatRoom = localChatRoom,
name = name,
country = country,
language = language,
userPublicKey = activeUserPublicKey,
kind = dialectEventTemplate.kind,
tags = dialectEventTemplate.tags,
content = dialectEventTemplate.content,
)
}.onFailure { error ->
logger.e("Failed to add dialect", error)
logger.e("Failed to propose a dialect for signing", error)
}.getOrNull()
if (dialect != null) {
if (session != null) {
nameField.clearText()
countryField.clearText()
languageField.clearText()
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(dialect.id)
onSuccess.invoke(session.id)
}
} else {
viewModelScope.launch(Dispatchers.Main) {
@@ -116,7 +144,7 @@ class AddDialectViewModel(
initialAddDialectUIState: AddDialectUIState = AddDialectUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
mantraRepository: MantraRepository
frostSigningRepository: FrostSigningRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AddDialectViewModel(
@@ -126,7 +154,7 @@ class AddDialectViewModel(
initialAddDialectUIState = initialAddDialectUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
mantraRepository = mantraRepository
frostSigningRepository = frostSigningRepository
)
}
}

View File

@@ -24,6 +24,8 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.CallMerge
import androidx.compose.material.icons.filled.FactCheck
import androidx.compose.material.icons.filled.Draw
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Upload
@@ -215,7 +217,7 @@ class ChatMessageListViewModel(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RenderMessages(onOpenSharedKey: () -> Unit) {
fun RenderMessages(onOpenSharedKey: () -> Unit, onOpenSigning: () -> Unit) {
Column(
modifier = Modifier.fillMaxWidth(),
@@ -270,8 +272,10 @@ class ChatMessageListViewModel(
val answeredRequests = chatRoomDetailMessageListUIState
.chatMessageList
.mapNotNull { request ->
val published = ChatMessage
.DKG_REQUEST_FULFILMENTS[request.chatMessage.messageType]
val published = (
ChatMessage.DKG_REQUEST_FULFILMENTS +
ChatMessage.FROST_REQUEST_FULFILMENTS
)[request.chatMessage.messageType]
?: return@mapNotNull null
val done = chatRoomDetailMessageListUIState.chatMessageList.any {
@@ -347,6 +351,20 @@ class ChatMessageListViewModel(
return@items
}
// Signing lines are the same kind of thing and get
// the same treatment -- nobody said them either --
// but they lead somewhere else, because what a
// reader needs from one is the event being signed
// rather than the state of the key.
if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) {
RitualNotice(
localChatMessage = localChatMessage,
isAnswered = localChatMessage.chatMessage.id in answeredRequests,
onClick = onOpenSigning
)
return@items
}
BoxWithConstraints(
modifier = Modifier.fillMaxWidth()
) {
@@ -537,6 +555,15 @@ private fun RitualNotice(
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload
ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck
ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw
ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload
ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups
ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw
ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium
ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle
ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline
ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw
else -> Icons.Default.PanTool
}
@@ -545,10 +572,15 @@ private fun RitualNotice(
// quiet; these are not.
// An answered request is history, not a summons: it keeps its stage's icon so
// the step is still recognisable, but drops the colour and the call to action.
val isRequest = chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES && !isAnswered
val isRequest = (
chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES ||
chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES
) && !isAnswered
val tint = when {
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED -> MaterialTheme.colorScheme.error
chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED ||
chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED ->
MaterialTheme.colorScheme.error
isRequest -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -578,7 +610,9 @@ private fun RitualNotice(
// from the joined profile rather than written into the content,
// so it follows a rename and is not stuck on the "LOADING..."
// placeholder a member is given the moment they are first seen.
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES) {
if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES ||
chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES
) {
withStyle(
SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey))
) {

View File

@@ -0,0 +1,156 @@
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 com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.nostr.frost.FrostSigningEvents
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.ui.view.state.FrostSigningUIState
class FrostSigningViewModel(
val chatRoomId: String,
val sessionId: String?,
val activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState,
val chatRepository: ChatRepository,
val frostSigningRepository: FrostSigningRepository,
): ViewModel() {
var frostSigningUIState: FrostSigningUIState by mutableStateOf(initialFrostSigningUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
/**
* Loads the room, then watches the session for as long as the screen lives.
*
* A session moves on messages arriving from other members, so a screen that
* read it once would sit still while the rest of the group signed around it.
*/
fun initiate() {
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
if (localChatRoom == null) {
frostSigningUIState = FrostSigningUIState.Error("Couldn't find the group")
return@launch
}
// A transcript line knows its room but not its session, so resolve one
// before watching anything.
val id = sessionId
?: frostSigningRepository.liveSessionForChatRoom(chatRoomId)?.id
if (id == null) {
frostSigningUIState =
FrostSigningUIState.Error("This group is not signing anything right now.")
return@launch
}
frostSigningUIState = FrostSigningUIState.Loaded(localChatRoom = localChatRoom)
combine(
frostSigningRepository.observeSessionById(id),
frostSigningRepository.observeMessages(id)
) { session, messages -> session to messages }
.collect { (session, messages) ->
frostSigningUIState = FrostSigningUIState.Loaded(
localChatRoom = localChatRoom,
session = session,
offeredNonce = messages
.filter { it.kind == FrostSigningEvents.NONCE }
.map { it.signerPublicKey }
.toSet(),
signed = messages
.filter { it.kind == FrostSigningEvents.PARTIAL_SIGNATURE }
.map { it.signerPublicKey }
.toSet()
)
}
}
}
/** The event the group is being asked to sign, for showing it before they agree. */
fun proposedEvent(session: FrostSigningSession): Event? =
Event.fromJsonOrNull(session.unsignedEventJson)
fun signedEvent(session: FrostSigningSession): Event? =
frostSigningRepository.signedEvent(session)
fun approve(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.approve(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
fun decline(onDone: () -> Unit) {
val state = frostSigningUIState as? FrostSigningUIState.Loaded ?: return
val sessionId = state.session?.id ?: return
if (isActionPending.value) return
isActionPending.value = true
viewModelScope.launch(Dispatchers.IO) {
frostSigningRepository.decline(
localChatRoom = state.localChatRoom,
sessionId = sessionId
)
isActionPending.value = false
viewModelScope.launch(Dispatchers.Main) { onDone.invoke() }
}
}
companion object {
private const val TAG = "FrostSigningViewModel"
fun factory(
chatRoomId: String,
sessionId: String?,
activeUserPublicKey: HexKey,
initialFrostSigningUIState: FrostSigningUIState = FrostSigningUIState.Loading,
chatRepository: ChatRepository,
frostSigningRepository: FrostSigningRepository,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
FrostSigningViewModel(
chatRoomId = chatRoomId,
sessionId = sessionId,
activeUserPublicKey = activeUserPublicKey,
initialFrostSigningUIState = initialFrostSigningUIState,
chatRepository = chatRepository,
frostSigningRepository = frostSigningRepository
)
}
}
}
}

View File

@@ -5,6 +5,13 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface AddDialectUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/**
* Whether the group holds a shared key. A dialect is signed into
* existence now rather than submitted, so a group without one cannot
* make one here at all.
*/
val canSign: Boolean = false,
): AddDialectUIState
data class Error(

View File

@@ -0,0 +1,26 @@
package press.mantra.compose.ui.view.state
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.database.model.FrostSigningSession
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
sealed interface FrostSigningUIState {
data class Loaded(
val localChatRoom: LocalChatRoom,
/** Null on the first emission, before the session has been collected. */
val session: FrostSigningSession? = null,
/** Who has offered a nonce, so the screen can name who it is waiting on. */
val offeredNonce: Set<HexKey> = emptySet(),
/** Who has signed their part. */
val signed: Set<HexKey> = emptySet(),
): FrostSigningUIState
data class Error(
val message: String
): FrostSigningUIState
data object Loading: FrostSigningUIState
}