diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt index f285d757..05f71985 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrNip17Dao.kt @@ -29,6 +29,58 @@ abstract class NostrNip17Dao( ) { val logger = Logger.withTag(TAG) + /** + * Stands up a NIP-17 group locally: no MLS group, no key packages, no invites. + * Membership under NIP-17 *is* the p-tag set on each message, so all that has to + * exist up front is the room and its participants — the first message carries the + * membership to everyone else. + * + * The id is [ChatRoom.deriveChatRoomId] over the members, the same aggregate the + * inbound path derives from an arriving gift wrap. That makes it idempotent: two + * people building the same group land on the same room rather than two. + */ + @Transaction + open suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String? = null, + description: String? = null, + relayHint: String? = Relays.DefaultDMRelayList.first().url + ): LocalChatRoom? { + val memberPublicKeys = (participantPublicKeys + userPublicKey).toSet() + val chatRoomId = ChatRoom.deriveChatRoomId(memberPublicKeys) + + logger.d("createNip17ChatRoom: $chatRoomId for ${memberPublicKeys.size} member(s)") + + database.chatRoomDao().findChatRoomById(chatRoomId)?.let { existingChatRoom -> + logger.i("Chat room $chatRoomId already exists; reusing it") + return existingChatRoom + } + + val chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = userPublicKey, + subject = subject, + description = description, + // No MLS state is what marks this a NIP-17 room: `sendChatMessage` reads + // exactly this to decide between a group event and gift wraps. + mlsGroupState = null + ) + database.chatRoomDao().upsert(chatRoom) + + database.participantDao().upsert( + memberPublicKeys.map { participantPublicKey -> + Participant( + participantPublicKey = participantPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + } + ) + + return database.chatRoomDao().findChatRoomById(chatRoomId) + } + @Transaction open suspend fun getOrCreateChatRoom( chatRoomId: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index fa1955df..1f0e7364 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -118,6 +118,23 @@ class DatabaseChatRepository( return database.marmotKeyPackageDao().observeMarmotKeyPackageForPublicKey(publicKey) } + override suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String?, + description: String? + ): LocalChatRoom? = try { + database.nostrNip17Dao().createNip17ChatRoom( + userPublicKey = userPublicKey, + participantPublicKeys = participantPublicKeys, + subject = subject, + description = description + ) + } catch (e: Throwable) { + logger.e("Error creating nip-17 chat room", e) + null + } + override suspend fun createMlsDirectMessage( name: String?, description: String?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 3f712124..2d74f0d2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -42,6 +42,17 @@ interface ChatRepository { suspend fun observeMarmotKeyPackageForPublicKey(publicKey: HexKey): Flow + /** + * Creates a NIP-17 group: participants and a room, no MLS group. Membership is + * the p-tag set carried by each message, so there is nothing to invite anyone to. + */ + suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String? = null, + description: String? = null + ): LocalChatRoom? + suspend fun createMlsDirectMessage( name: String? = null, description: String? = null, @@ -133,6 +144,15 @@ interface ChatRepository { TODO("Not yet implemented") } + override suspend fun createNip17ChatRoom( + userPublicKey: HexKey, + participantPublicKeys: List, + subject: String?, + description: String? + ): LocalChatRoom? { + return null + } + override suspend fun createMlsDirectMessage( name: String?, description: String?, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt index 24498cd0..7713c3df 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectChatRoomTypeViewModel.kt @@ -192,22 +192,21 @@ class SelectChatRoomTypeViewModel( privKey = nostrPrivateKey.value.toByteArray() ) - val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... - - // Convenient rooms keep the creator as the lone admin; robust rooms hand - // everyone the same authority. This is the whole behavioural difference - // between the two — the inbound path derives Participant.adminAt from - // exactly this list. - // TODO: Robust rooms still need the t-of-n approval itself, i.e. FROST - // signing over admin changes. Today the list is set but every admin can - // still commit on their own, and the quorum the user picked has nowhere to - // live: MIP-01's wire format is fixed, so it cannot ride in MarmotGroupData - // without breaking byte-compatibility with mdk/whitenoise. It needs a - // ChatRoom column (and the Room migration that comes with it). - val adminPubkeys = when (selectedChatRoomType.value) { - ChatRoomType.CONVENIENT -> listOf(keyPair.pubKey.toHexKey()) - ChatRoomType.ROBUST -> (listOf(keyPair.pubKey.toHexKey()) + memberPublicKeys).distinct() + when (selectedChatRoomType.value) { + ChatRoomType.CONVENIENT -> createMarmotChatRoom(keyPair, onNavigateToRoute) + ChatRoomType.ROBUST -> createNip17ChatRoom(keyPair, onNavigateToRoute) } + } + + /** + * Convenient rooms are Marmot/MLS: one admin, forward secrecy, and members added + * by commit — which is why every invite needs the peer's key package first. + */ + private fun createMarmotChatRoom( + keyPair: KeyPair, + onNavigateToRoute: (Route) -> Unit + ) { + val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost... // Stamp initial metadata via the shared factory so UI + CLI stay // byte-identical. Bake the MarmotGroupData extension into the @@ -221,8 +220,6 @@ class SelectChatRoomTypeViewModel( outboxRelays = Relays.DefaultDMRelayList.map { it.url }, name = name, description = description.orEmpty() - ).copy( - adminPubkeys = adminPubkeys ) val extras = listOf(metadata.toExtension()) @@ -284,6 +281,52 @@ class SelectChatRoomTypeViewModel( } } + /** + * Robust rooms are NIP-17: no MLS group, no admin, no privileged member. Membership + * is simply the p-tag set every message carries, which is why there is nothing to + * invite anyone to and no key package to wait on — everybody picked is in the room + * the moment it exists, and learns about it from the first message. + * + * TODO: the quorum the user picked has nowhere to live here. NIP-17 has no group + * state to change and so nothing to approve — the member set is whatever a message + * is addressed to, and a different set is simply a different room. Enforcing t-of-n + * needs a governance layer this protocol does not have. + */ + private fun createNip17ChatRoom( + keyPair: KeyPair, + onNavigateToRoute: (Route) -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.createNip17ChatRoom( + userPublicKey = keyPair.pubKey.toHexKey(), + participantPublicKeys = memberPublicKeys, + subject = name, + description = description + ) + + withContext(Dispatchers.Main) { + isActionPending.value = false + + if (localChatRoom == null) { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Something went wrong") + ) + return@withContext + } + + createdChatRoomId.value = localChatRoom.chatRoom.id + + onNavigateToRoute.invoke( + ChatRoomMessagingRoute( + activeUserPublicKey = keyPair.pubKey.toHexKey(), + chatRoomId = localChatRoom.chatRoom.id, + relayHint = null + ) + ) + } + } + } + /** * Adds every picked member to the freshly created group and returns the ones that * could not be added.