Foundation for creating direct message chat...

This commit is contained in:
Kgothatso Ngako
2026-07-06 02:21:01 +02:00
parent 006828b678
commit 0bee3e9f01
6 changed files with 168 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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