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