From 1bf3ec32acbba1d921732237742c9ddd6eb055ef Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 14 Jul 2026 00:31:13 +0200 Subject: [PATCH] Process group member changes on commit --- .../1.json | 11 +++- .../at/torch/compose/database/dao/NostrDao.kt | 38 +++++++----- .../compose/database/dao/ParticipantDao.kt | 4 ++ .../compose/database/model/Participant.kt | 38 ++++++++++-- .../compose/managers/MarmotInboundManager.kt | 62 +++++++++++++++++++ 5 files changed, 129 insertions(+), 24 deletions(-) diff --git a/composeApp/schemas/at.torch.compose.database.TorchDatabase/1.json b/composeApp/schemas/at.torch.compose.database.TorchDatabase/1.json index 2606f620..f6caf4fa 100644 --- a/composeApp/schemas/at.torch.compose.database.TorchDatabase/1.json +++ b/composeApp/schemas/at.torch.compose.database.TorchDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "6a176cdf1ee35e271c2a38b5955a7440", + "identityHash": "7e418251d31c1dd20c0ea92e14f2711c", "entities": [ { "tableName": "BroadcastNostrEventReceipt", @@ -2196,7 +2196,7 @@ }, { "tableName": "Participant", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -2221,6 +2221,11 @@ "columnName": "relayHint", "affinity": "TEXT" }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, { "fieldPath": "createdAt", "columnName": "createdAt", @@ -3462,7 +3467,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6a176cdf1ee35e271c2a38b5955a7440')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7e418251d31c1dd20c0ea92e14f2711c')" ] } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt index 8d98c88c..c1e157a3 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/NostrDao.kt @@ -30,6 +30,7 @@ import at.torch.compose.exceptions.MarmotWelcomeEventMissingKeyPackageEventIdExc import at.torch.compose.extensions.toHex import at.torch.compose.managers.MarmotInboundManager import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -398,6 +400,20 @@ abstract class NostrDao( groupEvent = groupEvent )?.let { groupEventResult -> logger.d("groupEventResult: $groupEventResult") + + when(groupEventResult) { + is GroupEventResult.CommitProcessed -> { + MarmotInboundManager.processGroupMembershipChanges( + database = database, + mlsGroup = mlsGroup, + localChatRoom = localChatRoom + ) + } + else -> { + logger.i("No app logic to handle: $groupEventResult") + } + } + // Save the mls chatRoom state... database.chatRoomDao().upsert( localChatRoom.chatRoom.copy( @@ -752,23 +768,11 @@ abstract class NostrDao( val normalizedRelayUrl = NormalizedRelayUrl(relayURL) - - val participants = group.members().mapNotNull { (leafIndex, leafNode) -> - val pubkey = when (val cred = leafNode.credential) { - is Credential.Basic -> cred.identity.toHexKey() - else -> null - } - - if (pubkey != null) { - Participant( - participantPublicKey = pubkey, - chatRoomId = nostrGroupId, - relayHint = group.currentMarmotData()?.relays?.firstOrNull() ?: normalizedRelayUrl.url - ) - } else { - null - } - } + val participants = Participant.fromMlsGroup( + mlsGroup = group, + nostrGroupId = nostrGroupId, + relayHint = normalizedRelayUrl.url + ) logger.d("Participants: $participants") // Sync missing participant profiles... diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ParticipantDao.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ParticipantDao.kt index 7d86ee67..9b063487 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ParticipantDao.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/dao/ParticipantDao.kt @@ -1,6 +1,7 @@ package at.torch.compose.database.dao import androidx.room3.Dao +import androidx.room3.Delete import androidx.room3.Query import androidx.room3.Upsert import at.torch.compose.database.model.Participant @@ -16,4 +17,7 @@ interface ParticipantDao { @Upsert suspend fun upsert(participants: List) + @Delete + suspend fun delete(participants: List) + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/Participant.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/Participant.kt index 38ac6ad2..68fddc5c 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/Participant.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/database/model/Participant.kt @@ -3,7 +3,14 @@ package at.torch.compose.database.model import androidx.room3.Entity import androidx.room3.ForeignKey import androidx.room3.PrimaryKey +import at.torch.compose.database.model.traits.BroadcastableEntity +import at.torch.compose.database.model.traits.LocalStoreEntity +import at.torch.compose.database.model.traits.SoftDeletableEntity +import at.torch.compose.database.model.traits.TimestampedEntity +import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.time.Clock import kotlin.time.Instant @@ -41,13 +48,36 @@ data class Participant( val chatRoomId: String, val relayHint: String?, + val adminAt: Instant? = null, override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = Clock.System.now(), override val savedAt: Instant = Clock.System.now(), override val deletedAt: Instant? = null, override val broadcastedAt: Instant? = null, -): at.torch.compose.database.model.traits.TimestampedEntity, - at.torch.compose.database.model.traits.LocalStoreEntity, - at.torch.compose.database.model.traits.BroadcastableEntity, - at.torch.compose.database.model.traits.SoftDeletableEntity +): TimestampedEntity, LocalStoreEntity, BroadcastableEntity, SoftDeletableEntity { + companion object { + fun fromMlsGroup( + mlsGroup: MlsGroup, + nostrGroupId: HexKey, + relayHint: String? + ): List { + return mlsGroup.members().mapNotNull { (leafIndex, leafNode) -> + val pubkey = when (val cred = leafNode.credential) { + is Credential.Basic -> cred.identity.toHexKey() + else -> null + } + + if (pubkey != null) { + Participant( + participantPublicKey = pubkey, + chatRoomId = nostrGroupId, + relayHint = mlsGroup.currentMarmotData()?.relays?.firstOrNull() ?: relayHint + ) + } else { + null + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/at/torch/compose/managers/MarmotInboundManager.kt b/composeApp/src/commonMain/kotlin/at/torch/compose/managers/MarmotInboundManager.kt index d9fdc544..5e3b242f 100644 --- a/composeApp/src/commonMain/kotlin/at/torch/compose/managers/MarmotInboundManager.kt +++ b/composeApp/src/commonMain/kotlin/at/torch/compose/managers/MarmotInboundManager.kt @@ -1,6 +1,7 @@ package at.torch.compose.managers import at.torch.compose.database.TorchDatabase +import at.torch.compose.database.model.Participant import at.torch.compose.database.model.intermdiate.LocalChatRoom import at.torch.compose.exceptions.MarmotMissingChatGroupException import at.torch.compose.exceptions.MarmotUnsupportedWireFormatException @@ -27,6 +28,7 @@ import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.time.Clock object MarmotInboundManager { private const val TAG = "MarmotInboundManager" @@ -50,6 +52,66 @@ object MarmotInboundManager { private val commitTracker = CommitOrdering.EpochCommitTracker() + suspend fun processGroupMembershipChanges( + database: TorchDatabase, + localChatRoom: LocalChatRoom, + mlsGroup: MlsGroup, + ) { + logger.d("processGroupMembershipChanges") + val adminPublicKeys = mlsGroup.currentMarmotData()?.adminPubkeys + + val commitedGroupMembers = Participant.fromMlsGroup( + mlsGroup = mlsGroup, + nostrGroupId = localChatRoom.chatRoom.id, + relayHint = mlsGroup.currentMarmotData()?.relays?.firstOrNull() + ) + val committedGroupMembersPublicKeys = commitedGroupMembers.map { it.participantPublicKey } + val excludedParticipants = localChatRoom.localParticipants.filter { localParticipant -> + committedGroupMembersPublicKeys.contains(localParticipant.participant.participantPublicKey).not() + }.map { localParticipant -> + localParticipant.participant.copy( + deletedAt = Clock.System.now() + ) + } + logger.d("excludedParticipants: $excludedParticipants") + val existingParticipantPublicKeys = localChatRoom.localParticipants.map { it.participant.participantPublicKey } + val newParticipants = commitedGroupMembers.filter { committedGroupMember -> + existingParticipantPublicKeys.contains(committedGroupMember.participantPublicKey) + }.map { newParticipant -> + newParticipant.copy( + adminAt = if (adminPublicKeys?.contains(newParticipant.participantPublicKey) == null) { + Clock.System.now() + } else { + null + } + ) + } + logger.d("newParticipants: $newParticipants") + val removedAdmins = localChatRoom.localParticipants.filter { localParticipant -> + localParticipant.participant.adminAt != null && adminPublicKeys?.contains(localParticipant.participant.participantPublicKey) == true + }.map { localParticipant -> + localParticipant.participant.copy( + adminAt = null + ) + } + logger.d("removedAdmins: $removedAdmins") + + val graduatedAdmins = localChatRoom.localParticipants.filter { localParticipant -> + localParticipant.participant.adminAt == null && adminPublicKeys?.contains(localParticipant.participant.participantPublicKey) == true + }.map { localParticipant -> + localParticipant.participant.copy( + adminAt = Clock.System.now() + ) + } + logger.d("graduatedAdmins: $graduatedAdmins") + + // Handle membership changes... + database.participantDao().upsert(removedAdmins) + database.participantDao().delete(excludedParticipants) + database.participantDao().upsert(newParticipants) + database.participantDao().upsert(graduatedAdmins) + } + suspend fun processGroupEvent( database: TorchDatabase, activeKeyPair: KeyPair,