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
This commit is contained in:
Claude
2026-04-05 22:10:33 +00:00
parent 0748d2a998
commit 3e49e650c2
7 changed files with 161 additions and 18 deletions
@@ -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)
}
}
}
@@ -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 -> {
@@ -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<GroupMemberInfo>()) }
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,
)
}
}
}
@@ -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)
}
}
/**
@@ -42,6 +42,9 @@ class MarmotGroupChatroom(
) : NotesGatherer {
var messages: Set<Note> = setOf()
var displayName = MutableStateFlow<String?>(null)
var description = MutableStateFlow<String?>(null)
var adminPubkeys = MutableStateFlow<List<HexKey>>(emptyList())
var relays = MutableStateFlow<List<String>>(emptyList())
var memberCount = MutableStateFlow(0)
var newestMessage: Note? = null
@@ -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<Extension>): 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<HexKey>()
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<String>()
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
}
}
}
@@ -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<com.vitorpamplona.quartz.marmot.mls.tree.Extension> get() = groupContext.extensions
// --- State Persistence ---