fix(marmot): new groups carry the encrypted-media policy so White Noise can send media

Groups created here left the encrypted-media-v2 component (0x800b) off, and
White Noise (MDK) refuses to send any attachment in a group without it:
InvalidMediaReference, 'group does not require encrypted media'. MDK treats
the component's presence as the requirement.

After the initial metadata commit, while the creator is still the only member,
commit the policy with the account's Blossom servers. Joiners get it in their
Welcome, so no member ever has to apply a later commit before sharing media.
The policy names the account's own Blossom servers, or, for an account
without a list, the default list the upload picker offers and falls back
through (the Group Info "Use encrypted attachments" button now works for those
accounts too, instead of asking them to add a server first). Best-effort: if
the commit fails the group is still created, and text and legacy MIP-04 media
keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-27 23:43:32 -04:00
co-authored by Claude Opus 5.5
parent a7ae40242a
commit 9d856315ad
3 changed files with 44 additions and 5 deletions
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
@@ -962,13 +963,40 @@ class AccountMarmotActions(
*
* The endpoints come from the account's own Blossom server list, because a
* policy naming servers the uploader does not use would describe a group
* nobody can actually post media to.
* nobody can actually post media to. An account without one uploads to the
* default list (the same one the upload picker offers and falls back
* through), so that is what its policy names.
*/
fun marmotMediaPolicyServers(): List<String> =
account.blossomServers.flow.value
.ifEmpty {
account.blossomServers.hostNameFlow.value
.filter { it.type == ServerType.Blossom }
.map { it.baseUrl }
}.mapNotNull { normalizedPolicyBaseUrl(it) }
.distinct()
.take(EncryptedMediaPolicyV2.MAX_ENTRIES)
/**
* [url] in the byte-exact form the media policy requires, or null when it has none.
* The component rejects a base URL that isn't its own WHATWG serialization, so the
* everyday spelling without a trailing slash (`https://cdn.nostrcheck.me`) failed the
* whole commit.
*/
private fun normalizedPolicyBaseUrl(url: String): String? =
try {
MarmotWebUrl.normalize(url, allowHttp = true, label = "base_url").also {
EncryptedMediaPolicyV2.requireNormalizedBaseUrl(it)
}
} catch (e: IllegalArgumentException) {
null
}
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val servers = account.blossomServers.flow.value
val servers = marmotMediaPolicyServers()
require(servers.isNotEmpty()) {
"Cannot enable encrypted media without at least one Blossom server configured"
}
@@ -2537,9 +2537,7 @@ class AccountViewModel(
fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null
/** True when this account has somewhere to upload a group's encrypted media. */
fun hasBlossomServers(): Boolean =
account.blossomServers.flow.value
.isNotEmpty()
fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty()
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) {
account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId)
@@ -67,9 +67,11 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Composable
fun CreateGroupScreen(
@@ -136,6 +138,17 @@ fun CreateGroupScreen(
description = groupDescription.trim(),
icon = iconChange,
)
// Commit the encrypted-media policy while we are the only member. White Noise
// (MDK) refuses to send any attachment in a group without one
// ("group does not require encrypted media"), and joiners learn it from the
// Welcome, so nobody has to apply a later commit to be able to share media.
// Best-effort: a group without it still works for text and legacy MIP-04.
try {
accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("CreateGroupScreen") { "Could not enable encrypted media for $nostrGroupId: ${e.message}" }
}
nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class)
} catch (e: Exception) {
isCreating = false