diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt index a29ee351..4d3a2d2b 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/MarmotOutboundDao.kt @@ -6,25 +6,34 @@ import at.torch.compose.database.TorchDatabase import at.torch.compose.database.model.BroadcastNostrEventRequest import at.torch.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation import at.torch.compose.database.model.ChatMessageNostrEventRelation +import at.torch.compose.database.model.ChatRoom import at.torch.compose.database.model.MarmotGroupEvent import at.torch.compose.database.model.MarmotInnerEvent import at.torch.compose.database.model.MarmotInnerEvent.Companion.disappearingExpiration +import at.torch.compose.database.model.MarmotKeyPackage import at.torch.compose.database.model.NostrEvent import at.torch.compose.database.model.intermdiate.LocalChatRoom import at.torch.compose.extensions.exporterSecret import at.torch.compose.extensions.toHex import at.torch.compose.nostr.Relays import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.time.Instant @@ -34,6 +43,108 @@ abstract class MarmotOutboundDao( ) { val logger = Logger.withTag(TAG) + @Transaction + open suspend fun createMlsDirectMessage( + userPublicKey: HexKey, + peerPublicKey: HexKey, + peerKeyPackage: MarmotKeyPackage, + relays: List = Relays.DefaultDMRelayList.map { it.url } + ): String { + // TODO: Might move this logic to a marmotOutboudDao for consistency... + val groupId = RandomInstance.bytes(32).toHexKey() + val mlsGroup = MlsGroup.create( + identity = userPublicKey.hexToByteArray(), + initialExtensions = listOf( + MarmotGroupData.bootstrap( + nostrGroupId = groupId, + creatorPubKey = userPublicKey, + outboxRelays = relays, + ).toExtension() + ) + ) + + // Invite peer... + val keyPackage = MlsKeyPackage.decodeTls( + TlsReader( + peerKeyPackage.tlsEncodedMarmotKeyPackage + ) + ) + + val credential = keyPackage.leafNode.credential + require(credential is Credential.Basic) { + "KeyPackage must use BasicCredential" + } + require(credential.identity.toHexKey() == peerPublicKey) { + "KeyPackage credential identity does not match memberPubKey" + } + + val retainedBefore = mlsGroup.retainedSecrets() + val commitResult = mlsGroup.addMember( + peerKeyPackage.tlsEncodedMarmotKeyPackage + ) + +// pushRetainedEpoch(nostrGroupId, retainedBefore) +// persistGroup(nostrGroupId) + + // CommitEvent... + val encryptedContent = encryptedCommitEvent( + mlsGroup, + commitBytes = commitResult.framedCommitBytes, + exporterKey = commitResult.preCommitExporterSecret + ) + + val template = GroupEvent.build( + encryptedContentBase64 = encryptedContent, + nostrGroupId = groupId + ) + + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val commitEvent: GroupEvent = ephemeralSigner.sign(template) + + // TODO: Welcome event? + // TODO: Save c0mmitEvent nostrEvent and broadcast it... + database.nostrEventDao().upsert( + NostrEvent( + id = commitEvent.id, + createdAt = Instant.fromEpochSeconds(commitEvent.createdAt), + content = commitEvent.content, + sig = commitEvent.sig, + tags = commitEvent.tags, + kind = commitEvent.kind, + pubKey = commitEvent.pubKey + ) + ) + + // Save Chat Room + database.chatRoomDao().upsert( + ChatRoom( + id = groupId, + userPublicKey = userPublicKey, + mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), + subject = null, + ) + ) + + database.broadcastNostrEventRequestDao().insert( + relays.map { + BroadcastNostrEventRequest( + nostrEventId = commitEvent.id, + relayURL = it + ) + } + ) + return groupId + } + + private fun encryptedCommitEvent( + mlsGroup: MlsGroup, + commitBytes: ByteArray, + exporterKey: ByteArray? = null, + ): String { + val outerKey = exporterKey ?: mlsGroup.exporterSecret() + + return GroupEventEncryption.encrypt(commitBytes, outerKey) + } @Transaction open suspend fun encryptAndSendMarmotInnerEvent( localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt index 367b22fc..c18a333d 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/repository/DatabaseChatRepository.kt @@ -1,18 +1,26 @@ package at.torch.compose.database.repository import at.torch.compose.database.model.ChatMessage +import at.torch.compose.database.model.ChatRoom import at.torch.compose.database.model.GiftWrapPayload import at.torch.compose.database.model.MarmotInnerEvent import at.torch.compose.database.model.MarmotKeyPackage import at.torch.compose.database.model.Participant import at.torch.compose.database.model.Profile import at.torch.compose.database.model.intermdiate.LocalProfile +import at.torch.compose.extensions.toHex +import at.torch.compose.nostr.Relays import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState +import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -23,6 +31,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -113,6 +122,18 @@ class DatabaseChatRepository( return database.profileDao().getProfileByPublicKey(publicKey) } + override suspend fun createMlsDirectMessage( + userPublicKey: HexKey, + peerPublicKey: HexKey, + peerKeyPackage: MarmotKeyPackage + ): String { + return database.marmotOutboundDao().createMlsDirectMessage( + userPublicKey = userPublicKey, + peerPublicKey = peerPublicKey, + peerKeyPackage = peerKeyPackage + ) + } + override suspend fun sendChatMessage( text: String, localChatRoom: at.torch.compose.database.model.intermdiate.LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt index 246dc3c7..443908c8 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/repository/ChatRepository.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Kind import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import fr.acinq.secp256k1.Hex import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -41,6 +42,12 @@ interface ChatRepository { suspend fun getProfileWithPublicKey(publicKey: HexKey): Profile? + suspend fun createMlsDirectMessage( + userPublicKey: HexKey, + peerPublicKey: HexKey, + peerKeyPackage: MarmotKeyPackage + ): String + suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, @@ -102,6 +109,14 @@ interface ChatRepository { TODO("Not yet implemented") } + override suspend fun createMlsDirectMessage( + userPublicKey: HexKey, + peerPublicKey: HexKey, + peerKeyPackage: MarmotKeyPackage + ): String { + TODO("Not yet implemented") + } + override suspend fun sendChatMessage( text: String, localChatRoom: LocalChatRoom, diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt index 5bd9816a..546a3776 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/composable/ChatRoomDetailScreen.kt @@ -68,7 +68,7 @@ fun ChatRoomDetailScreen( ) when (val chatRoomDetailUIState = chatRoomDetailViewModel.chatRoomDetailUIState) { - ChatRoomDetailUIState.Error -> { + is ChatRoomDetailUIState.Error -> { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -77,7 +77,7 @@ fun ChatRoomDetailScreen( modifier = Modifier.height(50.dp) ) Text( - text = "Something went wrong", + text = chatRoomDetailUIState.message, ) } } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt index 83ee9126..98cc5268 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -102,15 +102,26 @@ class ChatRoomDetailViewModel( } } - val localAccount = nostrRepository.observeProfile(chatRoomId).firstOrNull() + val profile = chatRepository.getProfileWithPublicKey(chatRoomId) - if (localAccount != null) { - logger.d("Create chat with: $localAccount") + if (profile != null) { + logger.d("Create chat with: $profile") // Check if we have a keyPackageEvent... - chatRepository.getMarmotKeyPackageForPublicKey(chatRoomId) + val keyPackageEvent = chatRepository.getMarmotKeyPackageForPublicKey(chatRoomId) + + if (keyPackageEvent == null) { + chatRoomDetailUIState = ChatRoomDetailUIState.Error("Missing Key Package") // TODO: Introduce missing key package UI State... + } else { + // TODO: Create new chat and invite local + chatRepository.createMlsDirectMessage( + userPublicKey = activeUserPublicKey, + peerPublicKey = chatRoomId, + peerKeyPackage = keyPackageEvent + ) + } } else { - chatRoomDetailUIState = ChatRoomDetailUIState.Error + chatRoomDetailUIState = ChatRoomDetailUIState.Error("Couldn't find profile...") } } } diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ChatRoomDetailUIState.kt index 604b5499..b3446ab0 100755 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -5,7 +5,9 @@ sealed interface ChatRoomDetailUIState { val localChatRoom: at.torch.compose.database.model.intermdiate.LocalChatRoom ): ChatRoomDetailUIState - data object Error: ChatRoomDetailUIState + data class Error( + val message: String + ): ChatRoomDetailUIState data object Loading: ChatRoomDetailUIState data object InitiatingNewChat: ChatRoomDetailUIState }