diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index f374d93f82..a00ff37947 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.nip01Core.core.Event @@ -53,7 +54,7 @@ class AccountMarmotActions( fun marmotGroupRelays(nostrGroupId: HexKey): Set { val groupRelays = account.marmotManager - ?.groupMetadata(nostrGroupId) + ?.groupView(nostrGroupId) ?.relays ?.mapNotNull { com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -386,12 +387,33 @@ class AccountMarmotActions( } /** - * Create a new Marmot MLS group. + * Create a new Marmot MLS group under the CURRENT profile. + * + * Not the legacy `0xF2EE` shape. A current-profile peer refuses a leaf + * with no account identity proof, and a legacy group cannot be upgraded + * into one afterwards — its existing leaves have no proofs to add — so the + * profile is decided here, once, and never migrated. Groups made the old + * way are joinable only by other legacy clients. + * + * The name, description and avatar arrive later through + * `updateMarmotGroupMetadata`; the routing component has to exist from + * epoch 0 because it carries the `nostr_group_id` every kind-445 event in + * this group is addressed to. */ - suspend fun createMarmotGroup(nostrGroupId: HexKey) { + suspend fun createMarmotGroup( + nostrGroupId: HexKey, + name: String = "", + description: String = "", + ) { val manager = account.marmotManager ?: return if (!account.isWriteable()) return - manager.createGroup(nostrGroupId) + manager.createCurrentProfileGroup( + nostrGroupId = nostrGroupId, + relays = + account.outboxRelays.flow.value + .map { it.url }, + profile = if (name.isEmpty() && description.isEmpty()) null else GroupProfileV1(name, description), + ) // Creator owns the group — mark it as "known" immediately so it // doesn't appear under "New Requests" before the first message. account.marmotGroupList.markAsKnown(nostrGroupId) @@ -416,9 +438,9 @@ class AccountMarmotActions( val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val metadata = manager.groupMetadata(nostrGroupId) - if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) { - val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList() + val view = manager.groupView(nostrGroupId) + if (view != null && view.adminPubkeys.contains(account.signer.pubKey)) { + val remaining = view.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList() // MIP-03 also rejects any GCE commit that leaves the group with zero // admins. If we're the only one, promote an arbitrary non-self // member to admin before stepping down. @@ -431,8 +453,7 @@ class AccountMarmotActions( if (heir != null) remaining.add(heir) } if (remaining.isNotEmpty()) { - val demoted = metadata.copy(adminPubkeys = remaining) - manager.updateGroupMetadata(nostrGroupId, demoted, groupRelays.toList()) + manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList()) } } @@ -541,17 +562,10 @@ class AccountMarmotActions( val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (metadata.adminPubkeys.contains(targetPubKey)) return + val view = manager.groupView(nostrGroupId) ?: return + if (view.adminPubkeys.contains(targetPubKey)) return - val outboxRelayStrings = - account.outboxRelays.flow.value - .map { it.url } - val updated = - metadata - .copy(adminPubkeys = metadata.adminPubkeys + targetPubKey) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) + manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList()) } /** @@ -568,20 +582,13 @@ class AccountMarmotActions( val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val metadata = manager.groupMetadata(nostrGroupId) ?: return - if (!metadata.adminPubkeys.contains(targetPubKey)) return - val remaining = metadata.adminPubkeys.filter { it != targetPubKey } + val view = manager.groupView(nostrGroupId) ?: return + if (!view.adminPubkeys.contains(targetPubKey)) return + val remaining = view.adminPubkeys.filter { it != targetPubKey } check(remaining.isNotEmpty()) { "Cannot revoke the last admin from a Marmot group (MIP-03)" } - val outboxRelayStrings = - account.outboxRelays.flow.value - .map { it.url } - val updated = - metadata - .copy(adminPubkeys = remaining) - .withMergedRelays(outboxRelayStrings) - updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays) + manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index b0e1359b5e..2081b2add3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -77,7 +77,7 @@ class MarmotGroupEventsEoseManager( } ?: continue // Use group-specific relays from MLS metadata; fall back to home relays - val metadata = manager.groupMetadata(groupId) + val metadata = manager.groupView(groupId) val groupRelays = metadata ?.relays diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 94b270b523..e3262b35e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -115,11 +115,12 @@ import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -2452,8 +2453,12 @@ class AccountViewModel( fun marmotMediaExporterSecret(nostrGroupId: String): ByteArray? = account.marmotManager?.mediaExporterSecret(nostrGroupId) - suspend fun createMarmotGroup(nostrGroupId: String) { - account.marmot.createMarmotGroup(nostrGroupId) + suspend fun createMarmotGroup( + nostrGroupId: String, + name: String = "", + description: String = "", + ) { + account.marmot.createMarmotGroup(nostrGroupId, name, description) } suspend fun publishMarmotKeyPackage() { @@ -2549,35 +2554,26 @@ class AccountViewModel( // overlap, so kind:445 messages never reach the other side. The // welcome carries the metadata, so the invitee learns the relays at // join time. - val outboxRelayStrings = - account.outboxRelays.flow.value - .map { it.url } - val currentMetadata = account.marmotManager?.groupMetadata(nostrGroupId) - val baseMetadata = - currentMetadata - ?.copy(name = name, description = description) - ?.withMergedRelays(outboxRelayStrings) - ?: MarmotGroupData.bootstrap( - nostrGroupId = nostrGroupId, - creatorPubKey = account.signer.pubKey, - outboxRelays = outboxRelayStrings, - name = name, - description = description, - ) - val updatedMetadata = - when (icon) { - is MarmotGroupIconChange.Keep -> baseMetadata - is MarmotGroupIconChange.Clear -> baseMetadata.withoutImage() - is MarmotGroupIconChange.Set -> - baseMetadata.withImage( - imageHash = icon.upload.imageHash, + val manager = account.marmotManager ?: return + val relays = account.marmot.marmotGroupRelays(nostrGroupId) + + manager.setGroupProfile(nostrGroupId, name, description, relays.toList()) + when (icon) { + is MarmotGroupIconChange.Keep -> Unit + is MarmotGroupIconChange.Clear -> manager.setGroupImage(nostrGroupId, null, relays.toList()) + is MarmotGroupIconChange.Set -> + manager.setGroupImage( + nostrGroupId, + GroupBlossomImageV1( + imageHash = icon.upload.imageHash.hexToByteArray(), imageKey = icon.upload.imageKey, imageNonce = icon.upload.imageNonce, imageUploadKey = icon.upload.imageUploadKey, - ) - } - val relays = account.marmot.marmotGroupRelays(nostrGroupId) - account.marmot.updateMarmotGroupMetadata(nostrGroupId, updatedMetadata, relays) + mediaType = icon.upload.mediaType, + ), + relays.toList(), + ) + } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 114cd8041c..ebf7997f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -87,7 +87,7 @@ fun CreateGroupScreen( scope.launch(Dispatchers.IO) { try { val nostrGroupId = RandomInstance.bytes(32).toHexKey() - accountViewModel.createMarmotGroup(nostrGroupId) + accountViewModel.createMarmotGroup(nostrGroupId, groupName.trim(), groupDescription.trim()) // Encrypt + upload the picked icon (if any) before the metadata commit, // so its parameters land in the group's MarmotGroupData extension. val iconChange = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt index f207c99a99..accf66d404 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/send/MarmotGroupIconUploader.kt @@ -51,6 +51,15 @@ class MarmotGroupIconUpload( val imageNonce: ByteArray, /** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */ val imageUploadKey: ByteArray, + /** + * Media type of the DECRYPTED image. + * + * MIP-01's blob never carried one, but the current profile's `0x8002` + * component requires it on a present image — and it is bound into the + * AEAD's AAD there, so a receiver cannot be steered into decoding the + * plaintext as a different type than the uploader meant. + */ + val mediaType: String, ) /** @@ -123,6 +132,7 @@ class MarmotGroupIconUploader( imageKey = cipher.imageKey, imageNonce = cipher.imageNonce, imageUploadKey = uploadKeySeed, + mediaType = uploadMime, ) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt index b0536cf90a..6e118ec485 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AwaitCommands.kt @@ -145,14 +145,14 @@ object AwaitCommands { ctx.syncIncoming(timeoutMs = 3_000) val match = ctx.marmot.activeGroupIds().firstOrNull { gid -> - wantedName == null || ctx.marmot.groupMetadata(gid)?.name == wantedName + wantedName == null || ctx.marmot.groupView(gid)?.name == wantedName } if (match != null) { Output.emit( mapOf( "group_id" to match, "mls_group_id" to ctx.marmot.mlsGroupIdHex(match), - "name" to (ctx.marmot.groupMetadata(match)?.name ?: ""), + "name" to (ctx.marmot.groupView(match)?.name ?: ""), "epoch" to ctx.marmot.groupEpoch(match), ), ) @@ -190,7 +190,7 @@ object AwaitCommands { if (!ctx.marmot.isMember(gid)) { null } else if (ctx.marmot - .groupMetadata(gid) + .groupView(gid) ?.adminPubkeys ?.contains(target) == true ) { @@ -215,7 +215,7 @@ object AwaitCommands { val deadline = System.currentTimeMillis() + timeoutSecs * 1000 while (System.currentTimeMillis() < deadline) { ctx.syncIncoming(timeoutMs = 3_000) - val name = ctx.marmot.groupMetadata(gid)?.name + val name = ctx.marmot.groupView(gid)?.name if (name == wantedName) { Output.emit(mapOf("group_id" to gid, "name" to name, "epoch" to ctx.marmot.groupEpoch(gid))) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt index 593de7d274..d8041a4ecf 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMembershipCommands.kt @@ -77,10 +77,10 @@ object GroupMembershipCommands { // leave the group with zero admins (admin depletion). If we're // the only admin, hand admin to another member first. val demoteEventId: String? = - ctx.marmot.groupMetadata(gid)?.let { metadata -> - if (!metadata.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null + ctx.marmot.groupView(gid)?.let { view -> + if (!view.adminPubkeys.contains(ctx.identity.pubKeyHex)) return@let null - val newAdmins = metadata.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList() + val newAdmins = view.adminPubkeys.filter { it != ctx.identity.pubKeyHex }.toMutableList() if (newAdmins.isEmpty()) { val heir = ctx.marmot @@ -90,8 +90,7 @@ object GroupMembershipCommands { ?: return@let null // solo group — skip demote, let MLS state cleanup handle it newAdmins.add(heir) } - val demoted = metadata.copy(adminPubkeys = newAdmins) - val demoteCommit = ctx.marmot.updateGroupMetadata(gid, demoted) + val demoteCommit = ctx.marmot.setGroupAdmins(gid, newAdmins, targets.toList()) ctx.publish(demoteCommit.signedEvent, targets) demoteCommit.signedEvent.id } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 5b5d749c0f..1b3ea5d64c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -24,12 +24,15 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.util.deleteOrWarn -import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.OutboundGroupEvent +import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import java.io.File @@ -44,7 +47,9 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "group rename ") - return edit(dataDir, rest[0]) { _, cur -> cur.copy(name = rest[1]) } + return commit(dataDir, rest[0]) { ctx, gid, view -> + ctx.marmot.setGroupProfile(gid, rest[1], view.description) + } } suspend fun promote( @@ -52,11 +57,9 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "group promote ") - return edit(dataDir, rest[0]) { ctx, cur -> + return commit(dataDir, rest[0]) { ctx, gid, view -> val newAdmin = ctx.requireUserHex(rest[1]) - val admins = cur.adminPubkeys.toMutableList() - if (newAdmin !in admins) admins.add(newAdmin) - cur.copy(adminPubkeys = admins) + ctx.marmot.setGroupAdmins(gid, (view.adminPubkeys + newAdmin).distinct()) } } @@ -65,10 +68,9 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.size < 2) return Output.error("bad_args", "group demote ") - return edit(dataDir, rest[0]) { ctx, cur -> + return commit(dataDir, rest[0]) { ctx, gid, view -> val target = ctx.requireUserHex(rest[1]) - val admins = cur.adminPubkeys.filter { it != target } - cur.copy(adminPubkeys = admins) + ctx.marmot.setGroupAdmins(gid, view.adminPubkeys.filter { it != target }) } } @@ -114,8 +116,17 @@ object GroupMetadataCommands { } } - return edit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { _, cur -> - cur.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, uploadKeySeed) + return commit(dataDir, gid, mapOf("image_hash" to enc.imageHash, "image_url" to uploadedUrl)) { ctx, resolved, _ -> + ctx.marmot.setGroupImage( + resolved, + GroupBlossomImageV1( + imageHash = enc.imageHash.hexToByteArray(), + imageKey = enc.imageKey, + imageNonce = enc.imageNonce, + imageUploadKey = uploadKeySeed, + mediaType = args.flag("mime") ?: "image/jpeg", + ), + ) } } @@ -125,40 +136,43 @@ object GroupMetadataCommands { rest: Array, ): Int { if (rest.isEmpty()) return Output.error("bad_args", "group clear-image ") - return edit(dataDir, rest[0]) { _, cur -> cur.withoutImage() } + return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupImage(gid, null) } } - private suspend fun edit( + /** + * Run one metadata commit and report it. + * + * The mutation goes through [MarmotManager]'s profile-agnostic setters + * rather than being applied to a legacy `MarmotGroupData` here. Building + * that blob locally was the bug: `groupMetadata` is null for every + * current-profile group, so this bootstrapped a legacy `0xF2EE` extension + * and committed it INTO a current-profile group — the rename appeared to + * succeed locally and every peer kept showing the old name. + */ + private suspend fun commit( dataDir: DataDir, rawGid: HexKey, extra: Map = emptyMap(), - mutate: suspend (Context, MarmotGroupData) -> MarmotGroupData, + mutate: suspend (Context, HexKey, MarmotManager.GroupView) -> OutboundGroupEvent, ): Int { Context.open(dataDir).use { ctx -> ctx.prepare() val gid = ctx.resolveGroupId(rawGid) ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Output.error("not_member", "not a member of group $gid") - val outboxUrls = ctx.outboxRelays().map { it.url } - val cur = - ctx.marmot.groupMetadata(gid) - ?: MarmotGroupData.bootstrap( - nostrGroupId = gid, - creatorPubKey = ctx.identity.pubKeyHex, - outboxRelays = outboxUrls, - ) - val updated = mutate(ctx, cur).withMergedRelays(outboxUrls) + val view = ctx.marmot.groupView(gid) ?: return Output.error("not_member", "not a member of group $gid") - val commit = ctx.marmot.updateGroupMetadata(gid, updated) + val commit = mutate(ctx, gid, view) val targets = ctx.marmotGroupRelays(gid).ifEmpty { ctx.outboxRelays() } val ack = ctx.publish(commit.signedEvent, targets) RawEventSupport.publishGuard(ack, commit.signedEvent.id)?.let { return it } + val after = ctx.marmot.groupView(gid) Output.emit( mapOf( "group_id" to gid, - "name" to updated.name, - "admins" to updated.adminPubkeys, + "name" to (after?.name ?: view.name), + "admins" to (after?.adminPubkeys ?: view.adminPubkeys), "epoch" to ctx.marmot.groupEpoch(gid), "commit_event_id" to commit.signedEvent.id, ) + RawEventSupport.ackFields(ack) + extra, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt index 46e06da5df..114118aff9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -35,7 +35,7 @@ object GroupReadCommands { val ids = ctx.marmot.activeGroupIds() val items = ids.map { id -> - val m = ctx.marmot.groupMetadata(id) + val m = ctx.marmot.groupView(id) mapOf( "group_id" to id, "name" to (m?.name ?: ""), @@ -58,7 +58,7 @@ object GroupReadCommands { val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) - val meta = ctx.marmot.groupMetadata(gid) + val meta = ctx.marmot.groupView(gid) val members = ctx.marmot.memberPubkeys(gid).map { mapOf("pubkey" to it.pubkey, "leaf_index" to it.leafIndex) @@ -109,7 +109,7 @@ object GroupReadCommands { val gid = ctx.resolveGroupId(rest[0]) ctx.syncIncoming() if (!ctx.marmot.isMember(gid)) return Output.error("not_member", gid) - val m = ctx.marmot.groupMetadata(gid) + val m = ctx.marmot.groupView(gid) Output.emit(mapOf("group_id" to gid, "admins" to (m?.adminPubkeys ?: emptyList()))) return 0 } 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 a2e8ecfc9c..08b968cf48 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 @@ -30,7 +30,9 @@ import com.vitorpamplona.quartz.marmot.MarmotWelcomeSender import com.vitorpamplona.quartz.marmot.OutboundGroupEvent import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult +import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 @@ -817,6 +819,146 @@ class MarmotManager( }.event } + /** + * A group's metadata read through whichever profile it actually uses. + * + * Every caller that wants a name, an admin list or an avatar wants this, + * not [groupMetadata]: the legacy accessor returns null for every + * current-profile group, so the UI, the CLI and the await verbs all showed + * a blank name and an empty admin set for groups that were perfectly fine. + */ + class GroupView( + val name: String, + val description: String, + val adminPubkeys: List, + val relays: List, + val image: MarmotGroupImage?, + /** True when the group requires `0x8009` — see [MarmotGroupState.isCurrentProfile]. */ + val isCurrentProfile: Boolean, + ) + + fun groupView(nostrGroupId: HexKey): GroupView? { + val group = groupManager.getGroup(nostrGroupId) ?: return null + val state = group.currentGroupState() + val legacy = MarmotGroupData.fromExtensions(group.extensions) + val image = state.image + return GroupView( + name = state.profile?.name?.takeIf { it.isNotEmpty() } ?: legacy?.name.orEmpty(), + description = state.profile?.description?.takeIf { it.isNotEmpty() } ?: legacy?.description.orEmpty(), + adminPubkeys = state.adminPolicy?.adminHexKeys ?: legacy?.adminPubkeys.orEmpty(), + relays = state.routing?.relays ?: legacy?.relays.orEmpty(), + image = + when { + image?.imageHash != null -> + MarmotGroupImage(image.imageHash!!.toHexKey(), image.imageKey!!, image.imageNonce!!) + + legacy?.hasImage() == true -> + MarmotGroupImage(legacy.imageHash!!, legacy.imageKey!!, legacy.imageNonce!!) + + else -> null + }, + isCurrentProfile = state.isCurrentProfile, + ) + } + + /** + * Rename a group, writing to whichever carrier the group actually uses. + * + * A current-profile group takes an `app_data_update` naming ONLY the + * profile component, so a concurrent admin-policy change does not lose its + * work to this one. A legacy group has no such separation — its single + * `0xF2EE` extension is rewritten whole. + */ + suspend fun setGroupProfile( + nostrGroupId: HexKey, + name: String, + description: String, + relays: List = groupRelays(nostrGroupId), + ): OutboundGroupEvent { + val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId") + if (!view.isCurrentProfile) { + val legacy = + groupMetadata(nostrGroupId) + ?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData") + return updateGroupMetadata(nostrGroupId, legacy.copy(name = name, description = description), relays) + } + return commitAndPublish(nostrGroupId, relays) { + groupManager.stageAppDataUpdate( + nostrGroupId, + GroupProfileV1.COMPONENT_ID, + GroupProfileV1(name, description).encode(), + ) + }.event + } + + /** + * Replace the group's admin set, writing to whichever carrier the group uses. + * + * Refuses an empty set. Both profiles reject a group with no admins — a + * groupthat can never again change its own state is not a state anyone + * can recover from, so the check belongs here rather than at each caller. + */ + suspend fun setGroupAdmins( + nostrGroupId: HexKey, + admins: List, + relays: List = groupRelays(nostrGroupId), + ): OutboundGroupEvent { + require(admins.isNotEmpty()) { "a Marmot group cannot be left with no admins" } + val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId") + if (!view.isCurrentProfile) { + val legacy = + groupMetadata(nostrGroupId) + ?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData") + return updateGroupMetadata(nostrGroupId, legacy.copy(adminPubkeys = admins), relays) + } + return commitAndPublish(nostrGroupId, relays) { + groupManager.stageAppDataUpdate( + nostrGroupId, + AdminPolicyV1.COMPONENT_ID, + AdminPolicyV1.ofHex(admins).encode(), + ) + }.event + } + + /** + * Set or clear the group avatar, writing to whichever carrier the group uses. + * + * [image] null clears it: the current profile removes the `0x8002` + * component outright rather than storing an "absent" encoding, so a group + * with no avatar carries no avatar state. + */ + suspend fun setGroupImage( + nostrGroupId: HexKey, + image: GroupBlossomImageV1?, + relays: List = groupRelays(nostrGroupId), + ): OutboundGroupEvent { + val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId") + if (!view.isCurrentProfile) { + val legacy = + groupMetadata(nostrGroupId) + ?: throw IllegalStateException("Legacy group $nostrGroupId has no MarmotGroupData") + val updated = + if (image?.imageHash == null) { + legacy.withoutImage() + } else { + legacy.withImage( + image.imageHash!!.toHexKey(), + image.imageKey!!, + image.imageNonce!!, + image.imageUploadKey!!, + ) + } + return updateGroupMetadata(nostrGroupId, updated, relays) + } + return commitAndPublish(nostrGroupId, relays) { + groupManager.stageAppDataUpdate( + nostrGroupId, + GroupBlossomImageV1.COMPONENT_ID, + image?.encode(), + ) + }.event + } + // --- KeyPackage Management --- /** @@ -1027,44 +1169,18 @@ class MarmotManager( nostrGroupId: HexKey, chatroom: MarmotGroupChatroom, ) { - // Read the current profile's components first, then the legacy - // 0xF2EE extension. Reading only the legacy one left every - // current-profile group with a blank name, no admins, no relays and no - // avatar in the UI — the group worked, it just looked empty. - val state = groupState(nostrGroupId) - val legacy = groupMetadata(nostrGroupId) - - val name = state?.profile?.name?.takeIf { it.isNotEmpty() } ?: legacy?.name - if (!name.isNullOrEmpty()) chatroom.displayName.value = name - - val description = state?.profile?.description?.takeIf { it.isNotEmpty() } ?: legacy?.description - if (!description.isNullOrEmpty()) chatroom.description.value = description - - val admins = state?.adminPolicy?.adminHexKeys ?: legacy?.adminPubkeys - if (admins != null) chatroom.adminPubkeys.value = admins - - val relays = state?.routing?.relays ?: legacy?.relays - if (relays != null) chatroom.relays.value = relays - - val image = state?.image - chatroom.image.value = - when { - image?.imageHash != null -> - MarmotGroupImage( - hash = image.imageHash!!.toHexKey(), - key = image.imageKey!!, - nonce = image.imageNonce!!, - ) - - legacy?.hasImage() == true -> - MarmotGroupImage( - hash = legacy.imageHash!!, - key = legacy.imageKey!!, - nonce = legacy.imageNonce!!, - ) - - else -> null - } + // Read through [groupView], not [groupMetadata]: the legacy accessor + // returns null for every current-profile group, which left them with a + // blank name, no admins, no relays and no avatar in the UI. The group + // worked; it just looked empty. + val view = groupView(nostrGroupId) + if (view != null) { + if (view.name.isNotEmpty()) chatroom.displayName.value = view.name + if (view.description.isNotEmpty()) chatroom.description.value = view.description + chatroom.adminPubkeys.value = view.adminPubkeys + chatroom.relays.value = view.relays + chatroom.image.value = view.image + } val previousCount = chatroom.members.value.size val members = memberPubkeys(nostrGroupId) chatroom.members.value = members diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt index 6f4d083f87..b3041004a3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AdminPolicyV1.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** @@ -119,6 +120,9 @@ data class AdminPolicyV1( return AdminPolicyV1(unique) } + /** Build from hex account keys, sorting and de-duplicating. */ + fun ofHex(keys: Collection): AdminPolicyV1 = of(keys.map { it.hexToByteArray() }) + fun decode(bytes: ByteArray): AdminPolicyV1 { val reader = TlsReader(bytes) val flat = reader.readOpaqueVarInt() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index e21eae6772..c5cdd43c14 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -439,6 +439,29 @@ class MlsGroupManager( } } + /** + * Stage an `app_data_update` proposal + Commit for one component. + * + * The current profile's carrier for group metadata. A GroupContextExtensions + * change rewrites the WHOLE extension set, which is what MIP-01 had to do + * with its single monolithic blob; `app_data_update` names one component + * id, so two admins changing different components do not clobber each + * other's work just by racing. + * + * Passing null [data] removes the component. + */ + suspend fun stageAppDataUpdate( + nostrGroupId: HexKey, + componentId: Int, + data: ByteArray?, + ): StagedCommit { + requireAdminForExtensionChange(requireGroup(nostrGroupId)) + return stage(nostrGroupId) { clone -> + if (data == null) clone.proposeAppDataRemoval(componentId) else clone.proposeAppDataUpdate(componentId, data) + clone.commit() + } + } + /** Stage a self-update / empty Commit. See [StagedCommit]. */ suspend fun stageCommit(nostrGroupId: HexKey): StagedCommit = stage(nostrGroupId) { it.commit() }