From 3e49e650c2c0e953f1f71dcd02f066ae3c665e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 22:10:33 +0000 Subject: [PATCH] feat: read MIP-01 group metadata from MLS GroupContext extensions Implement end-to-end reading of MarmotGroupData (extension 0xF2EE) from the MLS GroupContext.extensions and sync to the UI layer. Quartz (protocol): - MarmotGroupData.decodeTls(): deserialize from TLS wire format (version, nostrGroupId, name, description, admin pubkeys, relays, image fields) - MarmotGroupData.fromExtensions(): find and decode from extension list - MlsGroup.extensions: expose groupContext.extensions publicly Commons (shared logic): - MarmotGroupChatroom: add description, adminPubkeys, relays fields as MutableStateFlow for reactive UI - MarmotManager.groupMetadata(): extract MIP-01 data from group - MarmotManager.syncMetadataTo(): sync metadata + member count to a MarmotGroupChatroom Sync points (amethyst): - Account init: sync metadata after restoreAll() for all restored groups - GiftWrapEventHandler: sync after Welcome processing (group join) - GroupEventHandler: sync after CommitProcessed (epoch advances may update extensions via GroupContextExtensions proposals) UI (GroupInfoScreen): - Display group description from MIP-01 metadata - Display relay URLs from MIP-01 metadata - Mark admin members with "- admin" suffix (from adminPubkeys) https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM --- .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../loggedIn/DecryptAndIndexProcessor.kt | 7 ++ .../marmotGroup/MarmotGroupInfoScreen.kt | 46 ++++++---- .../amethyst/commons/marmot/MarmotManager.kt | 33 ++++++++ .../model/marmotGroups/MarmotGroupChatroom.kt | 3 + .../marmot/mip01Groups/MarmotGroupData.kt | 84 +++++++++++++++++++ .../quartz/marmot/mls/group/MlsGroup.kt | 1 + 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 8e1ff8ffd..f3f35b6dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2335,6 +2335,11 @@ class Account( if (marmotManager != null) { scope.launch(Dispatchers.IO) { marmotManager.restoreAll() + // Sync MIP-01 metadata from restored groups to chatrooms + marmotManager.activeGroupIds().forEach { groupId -> + val chatroom = marmotGroupList.getOrCreateGroup(groupId) + marmotManager.syncMetadataTo(groupId, chatroom) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index f4c99f093..8791601c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -326,6 +326,10 @@ class GiftWrapEventHandler( is WelcomeResult.Joined -> { Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}") + // Sync MIP-01 metadata from group extensions to chatroom + val chatroom = account.marmotGroupList.getOrCreateGroup(result.nostrGroupId) + manager.syncMetadataTo(result.nostrGroupId, chatroom) + // Rotate KeyPackages if needed if (result.needsKeyPackageRotation) { account.publishMarmotKeyPackages() @@ -483,6 +487,9 @@ class GroupEventHandler( is GroupEventResult.CommitProcessed -> { Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}") + // Sync MIP-01 metadata after epoch advance (extensions may have changed) + val chatroom = account.marmotGroupList.getOrCreateGroup(result.groupId) + manager.syncMetadataTo(result.groupId, chatroom) } is GroupEventResult.CommitPending -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 45876ad4d..4124df326 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -78,6 +78,9 @@ fun MarmotGroupInfoScreen( accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) } val displayName by chatroom.displayName.collectAsStateWithLifecycle() + val groupDescription by chatroom.description.collectAsStateWithLifecycle() + val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() + val groupRelays by chatroom.relays.collectAsStateWithLifecycle() var members by remember { mutableStateOf(emptyList()) } var showLeaveDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -130,18 +133,28 @@ fun MarmotGroupInfoScreen( style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) + if (!groupDescription.isNullOrEmpty()) { + Text( + text = groupDescription!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } Text( text = "${members.size} members", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), ) - Text( - text = "Group ID: ${nostrGroupId.take(16)}...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) + if (groupRelays.isNotEmpty()) { + Text( + text = "Relays: ${groupRelays.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId) if (epoch != null) { Text( @@ -170,6 +183,7 @@ fun MarmotGroupInfoScreen( MemberRow( member = member, isMe = member.pubkey == myPubkey, + isAdmin = member.pubkey in adminPubkeys, accountViewModel = accountViewModel, nav = nav, ) @@ -222,6 +236,7 @@ fun MarmotGroupInfoScreen( fun MemberRow( member: GroupMemberInfo, isMe: Boolean, + isAdmin: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { @@ -243,24 +258,19 @@ fun MemberRow( Column(modifier = Modifier.weight(1f)) { LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user -> val displayName = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}..." + val suffix = + buildString { + if (isMe) append(" (you)") + if (isAdmin) append(" - admin") + } Text( - text = - if (isMe) { - "$displayName (you)" - } else { - displayName - }, + text = "$displayName$suffix", style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal, + fontWeight = if (isMe || isAdmin) FontWeight.Bold else FontWeight.Normal, ) } - Text( - text = "Leaf #${member.leafIndex}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index 746cb1386..8122f358c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.MarmotOutboundProcessor @@ -31,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.WelcomeResult import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils +import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager @@ -287,6 +289,37 @@ class MarmotManager( * Get the current epoch for a group. */ fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch + + /** + * Get the MIP-01 group metadata from the MLS GroupContext extensions. + * Returns null if the group doesn't exist or has no MarmotGroupData extension. + */ + fun groupMetadata(nostrGroupId: HexKey): MarmotGroupData? { + val group = groupManager.getGroup(nostrGroupId) ?: return null + return MarmotGroupData.fromExtensions(group.extensions) + } + + /** + * Sync MIP-01 metadata and member info from the MLS group into a [MarmotGroupChatroom]. + * Call after joining a group, processing a commit, or restoring from storage. + */ + fun syncMetadataTo( + nostrGroupId: HexKey, + chatroom: MarmotGroupChatroom, + ) { + val metadata = groupMetadata(nostrGroupId) + if (metadata != null) { + if (metadata.name.isNotEmpty()) { + chatroom.displayName.value = metadata.name + } + if (metadata.description.isNotEmpty()) { + chatroom.description.value = metadata.description + } + chatroom.adminPubkeys.value = metadata.adminPubkeys + chatroom.relays.value = metadata.relays + } + chatroom.memberCount.value = memberCount(nostrGroupId) + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index d64e944ac..a681e7512 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -42,6 +42,9 @@ class MarmotGroupChatroom( ) : NotesGatherer { var messages: Set = setOf() var displayName = MutableStateFlow(null) + var description = MutableStateFlow(null) + var adminPubkeys = MutableStateFlow>(emptyList()) + var relays = MutableStateFlow>(emptyList()) var memberCount = MutableStateFlow(0) var newestMessage: Note? = null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index 5e7103584..0adb3d2c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.marmot.mip01Groups import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.tree.Extension import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** * Marmot Group Data Extension (MIP-01) — extension ID 0xF2EE. @@ -91,5 +94,86 @@ data class MarmotGroupData( /** MLS extension type identifier for marmot_group_data */ const val EXTENSION_ID: UShort = 0xF2EEu + const val EXTENSION_ID_INT: Int = 0xF2EE + + /** + * Find and decode the MarmotGroupData extension from a list of MLS extensions. + * Returns null if no extension with type 0xF2EE is present. + */ + fun fromExtensions(extensions: List): MarmotGroupData? { + val ext = extensions.find { it.extensionType == EXTENSION_ID_INT } ?: return null + return decodeTls(ext.extensionData) + } + + /** + * Decode MarmotGroupData from TLS wire format bytes. + * + * Wire format: + * ``` + * uint16 version + * opaque nostr_group_id[32] + * opaque name<0..2^16-1> + * opaque description<0..2^16-1> + * opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys + * RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings + * opaque image_hash<0..32> + * opaque image_key<0..32> + * opaque image_nonce<0..12> + * opaque image_upload_key<0..32> + * ``` + */ + fun decodeTls(data: ByteArray): MarmotGroupData? = + try { + val reader = TlsReader(data) + val version = reader.readUint16() + + val nostrGroupIdBytes = reader.readBytes(32) + val nostrGroupId = nostrGroupIdBytes.toHexKey() + + val nameBytes = reader.readOpaque2() + val name = nameBytes.decodeToString() + + val descriptionBytes = reader.readOpaque2() + val description = descriptionBytes.decodeToString() + + // Admin pubkeys: concatenated 32-byte keys within a length-prefixed block + val adminBlock = reader.readOpaque2() + val adminPubkeys = mutableListOf() + var i = 0 + while (i + 32 <= adminBlock.size) { + adminPubkeys.add(adminBlock.copyOfRange(i, i + 32).toHexKey()) + i += 32 + } + + // Relays: length-prefixed block of length-prefixed UTF-8 strings + val relaysBlock = reader.readOpaque2() + val relays = mutableListOf() + val relayReader = TlsReader(relaysBlock) + while (relayReader.hasRemaining) { + val relayBytes = relayReader.readOpaque2() + relays.add(relayBytes.decodeToString()) + } + + // Optional fields — read if remaining + val imageHash = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() }?.toHexKey() else null + val imageKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null + val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null + val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null + + MarmotGroupData( + version = version, + nostrGroupId = nostrGroupId, + name = name, + description = description, + adminPubkeys = adminPubkeys, + relays = relays, + imageHash = imageHash, + imageKey = imageKey, + imageNonce = imageNonce, + imageUploadKey = imageUploadKey, + ) + } catch (e: Exception) { + null + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index d69864f12..a3bd23996 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -107,6 +107,7 @@ class MlsGroup private constructor( val groupId: ByteArray get() = groupContext.groupId val epoch: Long get() = groupContext.epoch val leafIndex: Int get() = myLeafIndex + val extensions: List get() = groupContext.extensions // --- State Persistence ---