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) {
|
if (marmotManager != null) {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
marmotManager.restoreAll()
|
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 -> {
|
is WelcomeResult.Joined -> {
|
||||||
Log.d("GiftWrapEventHandler", "Joined Marmot group ${result.nostrGroupId}")
|
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
|
// Rotate KeyPackages if needed
|
||||||
if (result.needsKeyPackageRotation) {
|
if (result.needsKeyPackageRotation) {
|
||||||
account.publishMarmotKeyPackages()
|
account.publishMarmotKeyPackages()
|
||||||
@@ -483,6 +487,9 @@ class GroupEventHandler(
|
|||||||
|
|
||||||
is GroupEventResult.CommitProcessed -> {
|
is GroupEventResult.CommitProcessed -> {
|
||||||
Log.d("GroupEventHandler", "Commit processed for group ${result.groupId}, epoch=${result.newEpoch}")
|
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 -> {
|
is GroupEventResult.CommitPending -> {
|
||||||
|
|||||||
+28
-18
@@ -78,6 +78,9 @@ fun MarmotGroupInfoScreen(
|
|||||||
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||||
}
|
}
|
||||||
val displayName by chatroom.displayName.collectAsStateWithLifecycle()
|
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 members by remember { mutableStateOf(emptyList<GroupMemberInfo>()) }
|
||||||
var showLeaveDialog by remember { mutableStateOf(false) }
|
var showLeaveDialog by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -130,18 +133,28 @@ fun MarmotGroupInfoScreen(
|
|||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
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(
|
||||||
text = "${members.size} members",
|
text = "${members.size} members",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
)
|
)
|
||||||
Text(
|
if (groupRelays.isNotEmpty()) {
|
||||||
text = "Group ID: ${nostrGroupId.take(16)}...",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
text = "Relays: ${groupRelays.joinToString(", ")}",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
|
val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId)
|
||||||
if (epoch != null) {
|
if (epoch != null) {
|
||||||
Text(
|
Text(
|
||||||
@@ -170,6 +183,7 @@ fun MarmotGroupInfoScreen(
|
|||||||
MemberRow(
|
MemberRow(
|
||||||
member = member,
|
member = member,
|
||||||
isMe = member.pubkey == myPubkey,
|
isMe = member.pubkey == myPubkey,
|
||||||
|
isAdmin = member.pubkey in adminPubkeys,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
@@ -222,6 +236,7 @@ fun MarmotGroupInfoScreen(
|
|||||||
fun MemberRow(
|
fun MemberRow(
|
||||||
member: GroupMemberInfo,
|
member: GroupMemberInfo,
|
||||||
isMe: Boolean,
|
isMe: Boolean,
|
||||||
|
isAdmin: Boolean,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -243,24 +258,19 @@ fun MemberRow(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user ->
|
LoadUser(baseUserHex = member.pubkey, accountViewModel = accountViewModel) { user ->
|
||||||
val displayName = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}..."
|
val displayName = user?.toBestDisplayName() ?: "${member.pubkey.take(16)}..."
|
||||||
|
val suffix =
|
||||||
|
buildString {
|
||||||
|
if (isMe) append(" (you)")
|
||||||
|
if (isAdmin) append(" - admin")
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text =
|
text = "$displayName$suffix",
|
||||||
if (isMe) {
|
|
||||||
"$displayName (you)"
|
|
||||||
} else {
|
|
||||||
displayName
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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
|
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.GroupEventResult
|
||||||
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
|
import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor
|
||||||
import com.vitorpamplona.quartz.marmot.MarmotOutboundProcessor
|
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.KeyPackageEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager
|
||||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
|
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.mip02Welcome.WelcomeEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||||
@@ -287,6 +289,37 @@ class MarmotManager(
|
|||||||
* Get the current epoch for a group.
|
* Get the current epoch for a group.
|
||||||
*/
|
*/
|
||||||
fun groupEpoch(nostrGroupId: HexKey): Long? = groupManager.getGroup(nostrGroupId)?.epoch
|
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 {
|
) : NotesGatherer {
|
||||||
var messages: Set<Note> = setOf()
|
var messages: Set<Note> = setOf()
|
||||||
var displayName = MutableStateFlow<String?>(null)
|
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 memberCount = MutableStateFlow(0)
|
||||||
var newestMessage: Note? = null
|
var newestMessage: Note? = null
|
||||||
|
|
||||||
|
|||||||
+84
@@ -21,7 +21,10 @@
|
|||||||
package com.vitorpamplona.quartz.marmot.mip01Groups
|
package com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
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.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marmot Group Data Extension (MIP-01) — extension ID 0xF2EE.
|
* Marmot Group Data Extension (MIP-01) — extension ID 0xF2EE.
|
||||||
@@ -91,5 +94,86 @@ data class MarmotGroupData(
|
|||||||
|
|
||||||
/** MLS extension type identifier for marmot_group_data */
|
/** MLS extension type identifier for marmot_group_data */
|
||||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
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 groupId: ByteArray get() = groupContext.groupId
|
||||||
val epoch: Long get() = groupContext.epoch
|
val epoch: Long get() = groupContext.epoch
|
||||||
val leafIndex: Int get() = myLeafIndex
|
val leafIndex: Int get() = myLeafIndex
|
||||||
|
val extensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> get() = groupContext.extensions
|
||||||
|
|
||||||
// --- State Persistence ---
|
// --- State Persistence ---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user