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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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 -> {
|
||||
|
||||
+28
-18
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
|
||||
+84
@@ -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 ---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user