Process group member changes on commit

This commit is contained in:
Kgothatso Ngako
2026-07-14 00:31:13 +02:00
parent be6c49ead0
commit 1bf3ec32ac
5 changed files with 129 additions and 24 deletions

View File

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

View File

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

View File

@@ -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<Participant>)
@Delete
suspend fun delete(participants: List<Participant>)
}

View File

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

View File

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