diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt index 78d04c63..0b68a2b2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/FrostSigningSessionDao.kt @@ -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 + @Upsert suspend fun upsert(frostSigningSession: FrostSigningSession) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index 435a1833..feba4e8c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -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(), diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt new file mode 100644 index 00000000..abd6ed08 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseFrostSigningRepository.kt @@ -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 = + database.frostSigningSessionDao().observeSessionById(sessionId) + + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) + + override fun observeMessages(sessionId: String): Flow> = + 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>, + 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" + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt index 47355af6..2bca8145 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/FrostSigningManager.kt @@ -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. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt new file mode 100644 index 00000000..f3412414 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/FrostSigningRepository.kt @@ -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 + + /** Every session the room has run, newest first. Unlike a ceremony, signing recurs. */ + fun observeSessionsForChatRoom(chatRoomId: String): Flow> + + fun observeMessages(sessionId: String): Flow> + + 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>, + 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 = flowOf(null) + + override fun observeSessionsForChatRoom(chatRoomId: String): Flow> = + flowOf(emptyList()) + + override fun observeMessages(sessionId: String): Flow> = + 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>, + 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 + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index b02663ca..40b8cf69 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -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 = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 3cd87d96..c77fa7ef 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -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 + ) + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt new file mode 100644 index 00000000..38282f75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -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 = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5dad096d..354ffafa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 { + 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 { inclusive = true } } @@ -844,6 +852,20 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + FrostSigningScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + sessionId = route.sessionId, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt new file mode 100644 index 00000000..3d7575f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/FrostSigningRoute.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt index f0f655e7..a77d4593 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/AddDialectViewModel.kt @@ -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 = 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 ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 27d40b07..2d2a20d6 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -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)) ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt new file mode 100644 index 00000000..b2942491 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FrostSigningViewModel.kt @@ -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 = 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 + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt index 3f66e18b..8f41867d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/AddDialectUIState.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt new file mode 100644 index 00000000..ccf8cdcb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/FrostSigningUIState.kt @@ -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 = emptySet(), + + /** Who has signed their part. */ + val signed: Set = emptySet(), + ): FrostSigningUIState + + data class Error( + val message: String + ): FrostSigningUIState + + data object Loading: FrostSigningUIState +}