From 42e226bf8d0c7f2ffb0190d35e4e4163facc03ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 16:59:19 +0000 Subject: [PATCH 01/34] feat: show Marmot group relays as clickable icons with activity status Replaces the comma-separated relay text on the Marmot Group Info screen with a FlowRow of 55dp relay avatars that open RelayInfo on tap and copy the URL on long-press, matching the relay-icon pattern used elsewhere in the app. To surface whether each configured relay is actually carrying traffic for the group, MarmotGroupChatroom now tracks the most recent kind:445 event timestamp observed from each delivering relay and the info screen renders a green dot when a relay has delivered a group event within the last 7 days (gray otherwise). --- .../loggedIn/DecryptAndIndexProcessor.kt | 3 + .../marmotGroup/MarmotGroupInfoScreen.kt | 145 +++++++++++++++++- .../model/marmotGroups/MarmotGroupChatroom.kt | 20 +++ 3 files changed, 163 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 9e24c48ed..552bd56eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -566,6 +566,9 @@ class GroupEventHandler( return } + val chatroom = account.marmotGroupList.getOrCreateGroup(groupId) + eventNote.relays.forEach { chatroom.recordRelayActivity(it, event.createdAt) } + try { val result = manager.processGroupEvent(event) Log.d("MarmotDbg") { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt index 706e10f64..ea2fe7886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupInfoScreen.kt @@ -21,15 +21,24 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ExitToApp @@ -54,18 +63,29 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.marmot.GroupMemberInfo +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -84,6 +104,7 @@ fun MarmotGroupInfoScreen( val groupDescription by chatroom.description.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() val groupRelays by chatroom.relays.collectAsStateWithLifecycle() + val relayActivity by chatroom.relayActivity.collectAsStateWithLifecycle() val members by chatroom.members.collectAsStateWithLifecycle() var showLeaveDialog by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) } @@ -154,11 +175,11 @@ fun MarmotGroupInfoScreen( modifier = Modifier.padding(top = 4.dp), ) if (groupRelays.isNotEmpty()) { - Text( - text = "Relays: ${groupRelays.joinToString(", ")}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), + GroupRelayList( + relayUrls = groupRelays, + relayActivity = relayActivity, + accountViewModel = accountViewModel, + nav = nav, ) } val epoch = accountViewModel.account.marmotManager?.groupEpoch(nostrGroupId) @@ -338,3 +359,117 @@ fun LeaveGroupDialog( }, ) } + +/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */ +private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60 + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun GroupRelayList( + relayUrls: List, + relayActivity: Map, + accountViewModel: AccountViewModel, + nav: INav, +) { + val normalized = + remember(relayUrls) { + relayUrls.mapNotNull { url -> url.normalizeRelayUrlOrNull()?.let { url to it } } + } + + if (normalized.isEmpty()) return + + Column(modifier = Modifier.padding(top = 8.dp)) { + Text( + text = "Relays", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val nowSeconds = System.currentTimeMillis() / 1000L + normalized.forEach { (raw, relay) -> + val lastSeen = relayActivity[relay] + val isActive = lastSeen != null && (nowSeconds - lastSeen) <= RELAY_ACTIVITY_WINDOW_SECS + GroupRelayTile( + relay = relay, + fallbackUrl = raw, + isActive = isActive, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun GroupRelayTile( + relay: NormalizedRelayUrl, + fallbackUrl: String, + isActive: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relayInfo by loadRelayInfo(relay) + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val clickableModifier = + remember(relay) { + Modifier + .size(55.dp) + .combinedClickable( + indication = ripple24dp, + interactionSource = MutableInteractionSource(), + onLongClick = { + scope.launch { + clipboardManager.setText(relay.url) + } + }, + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, + ) + } + + Box( + modifier = clickableModifier, + contentAlignment = Alignment.Center, + ) { + RenderRelayIcon( + displayUrl = relayInfo.id ?: fallbackUrl, + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + pingInMs = 0, + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + iconModifier = LargeRelayIconModifier, + ) + + val dotColor = + if (isActive) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.placeholderText + } + + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(2.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(dotColor), + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index bf9ec65a0..537976e56 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -27,9 +27,11 @@ import com.vitorpamplona.amethyst.commons.model.ListChange import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import java.lang.ref.WeakReference /** @@ -51,6 +53,14 @@ class MarmotGroupChatroom( var newestMessage: Note? = null val unreadCount = MutableStateFlow(0) + /** + * Tracks the most recent createdAt (seconds) of a kind:445 group event + * observed from each relay, keyed by the relay that delivered it. Used by + * the Group Info screen to flag which configured relays are actively + * carrying traffic for this MLS group. + */ + val relayActivity = MutableStateFlow>(emptyMap()) + /** * True if the local user has ever sent an application message in this * group, OR explicitly created/owns it. Used by list UIs to split groups @@ -134,6 +144,16 @@ class MarmotGroupChatroom( unreadCount.value = 0 } + fun recordRelayActivity( + relay: NormalizedRelayUrl, + createdAt: Long, + ) { + relayActivity.update { current -> + val existing = current[relay] ?: 0L + if (createdAt > existing) current + (relay to createdAt) else current + } + } + fun pruneMessagesToTheLatestOnly(): Set { val sorted = messages.sortedWith(DefaultFeedOrder) val toKeep = From 1569b456715bb8056e939d73ec3a78eb21bff5ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:06:51 +0000 Subject: [PATCH 02/34] fix: align Marmot implementation with MIP specs for interop with MDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings Amethyst's Marmot code into wire-level interoperability with the reference MDK (Rust) implementation by resolving four spec violations and tightening the epoch lookback window: - MIP-01 (NostrGroupData extension): switch to QUIC-style VarInt length prefixes (RFC 9000 §16) instead of fixed uint16. MIP-01 mandates this encoding (the one produced by the Rust `tls_codec` crate v0.4+), so Amethyst's previous uint16 framing could not round-trip with any MDK peer. admin_pubkeys, relays (outer + each inner), name, description, image_*, and disappearing_message_secs now all use VarInt prefixes. - MIP-01: enforce "admin_pubkeys MUST NOT contain duplicates" in the MarmotGroupData constructor. - MIP-05 (Push Notifications): token plaintext padded size was 220 (→ 280 encrypted); spec MUSTs are exactly 1024 bytes plaintext (1084 encrypted). A server expecting MIP-05 tokens would reject Amethyst's frames outright. - MIP-05: HKDF IKM was sha256(shared_point); spec requires the raw 32-byte ECDH x-coordinate. The extra sha256 produced a different PRK, so token ciphertext authenticated only within Amethyst. Removed the hash; ECDH already returns the x-only compact form via pubKeyTweakMulCompact. - MIP-03: bump EPOCH_RETENTION_WINDOW from 2 to 5 to match MDK's DEFAULT_EPOCH_LOOKBACK, so late-arriving GroupEvents whose ChaCha20 outer key was derived from a prior epoch's exporter secret can still be decrypted after a Commit advances the group. Hand-crafted MarmotGroupData test blobs were updated to emit VarInt length prefixes. --- .../marmot/mip01Groups/MarmotGroupData.kt | 82 +++++++++++-------- .../mip05PushNotifications/TokenEncryption.kt | 37 ++++----- .../mip05PushNotifications/tags/TokenTag.kt | 9 +- .../marmot/mls/group/MlsGroupManager.kt | 13 ++- .../quartz/marmot/MarmotMipComplianceTest.kt | 24 +++--- 5 files changed, 92 insertions(+), 73 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index e326bbcf3..534bebd8c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -100,6 +100,9 @@ data class MarmotGroupData( require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) { "disappearing_message_secs must be > 0 when set (MIP-01)" } + require(adminPubkeys.size == adminPubkeys.toSet().size) { + "MarmotGroupData.admin_pubkeys MUST NOT contain duplicates (MIP-01)" + } } /** Whether the given pubkey is an admin of this group */ @@ -111,33 +114,41 @@ data class MarmotGroupData( /** * Encode this MarmotGroupData to TLS wire format bytes. * Mirrors the [decodeTls] format. + * + * Per MIP-01, all variable-length vectors use QUIC-style variable-length integer + * (VarInt) length prefixes, as implemented by the Rust `tls_codec` crate (v0.4+): + * - lengths 0..63 → 1 byte (high bits 00) + * - lengths 64..16383 → 2 bytes (high bits 01) + * - lengths 16384+ → 4 bytes (high bits 10) */ fun encodeTls(): ByteArray { val writer = TlsWriter() writer.putUint16(version) writer.putBytes(nostrGroupId.hexToByteArray()) - writer.putOpaque2(name.encodeToByteArray()) - writer.putOpaque2(description.encodeToByteArray()) + writer.putOpaqueVarInt(name.encodeToByteArray()) + writer.putOpaqueVarInt(description.encodeToByteArray()) - // Admin pubkeys: concatenated 32-byte keys within a length-prefixed block + // admin_pubkeys: Vec<[u8;32]> — outer VarInt covers total bytes, each 32-byte + // key is fixed-size with no inner length prefix. val adminBytes = ByteArray(adminPubkeys.size * 32) adminPubkeys.forEachIndexed { index, key -> key.hexToByteArray().copyInto(adminBytes, index * 32) } - writer.putOpaque2(adminBytes) + writer.putOpaqueVarInt(adminBytes) - // Relays: length-prefixed block of length-prefixed UTF-8 strings + // relays: Vec> — outer VarInt covers total bytes, each inner relay + // string is VarInt-length-prefixed UTF-8. val relayWriter = TlsWriter() for (relay in relays) { - relayWriter.putOpaque2(relay.encodeToByteArray()) + relayWriter.putOpaqueVarInt(relay.encodeToByteArray()) } - writer.putOpaque2(relayWriter.toByteArray()) + writer.putOpaqueVarInt(relayWriter.toByteArray()) - // Optional image fields - writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0)) - writer.putOpaque2(imageKey ?: ByteArray(0)) - writer.putOpaque2(imageNonce ?: ByteArray(0)) - writer.putOpaque2(imageUploadKey ?: ByteArray(0)) + // Optional image fields — empty Vec encodes as a single zero byte (VarInt(0)). + writer.putOpaqueVarInt(imageHash?.hexToByteArray() ?: ByteArray(0)) + writer.putOpaqueVarInt(imageKey ?: ByteArray(0)) + writer.putOpaqueVarInt(imageNonce ?: ByteArray(0)) + writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0)) // v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs) val disappearingBytes = @@ -150,7 +161,7 @@ data class MarmotGroupData( } out } ?: ByteArray(0) - writer.putOpaque2(disappearingBytes) + writer.putOpaqueVarInt(disappearingBytes) return writer.toByteArray() } @@ -182,19 +193,22 @@ data class MarmotGroupData( /** * Decode MarmotGroupData from TLS wire format bytes. * + * Per MIP-01, all variable-length vectors use QUIC-style VarInt length prefixes + * (`tls_codec` v0.4+). The TLS comment syntax below uses `` to denote VarInt. + * * Wire format (v3): * ``` * uint16 version // rejected if 0 or unsupported * 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> - * opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0) + * opaque name + * opaque description + * opaque admin_pubkeys // concatenated 32-byte keys + * RelayUrl relays // VarInt-length-prefixed UTF-8 strings + * opaque image_hash + * opaque image_key + * opaque image_nonce + * opaque image_upload_key + * opaque disappearing_message_secs // v3+: 0 bytes or 8-byte uint64 (reject 0) * ``` * * Unknown trailing bytes from future versions are silently ignored for @@ -209,14 +223,14 @@ data class MarmotGroupData( val nostrGroupIdBytes = reader.readBytes(32) val nostrGroupId = nostrGroupIdBytes.toHexKey() - val nameBytes = reader.readOpaque2() + val nameBytes = reader.readOpaqueVarInt() val name = nameBytes.decodeToString() - val descriptionBytes = reader.readOpaque2() + val descriptionBytes = reader.readOpaqueVarInt() val description = descriptionBytes.decodeToString() - // Admin pubkeys: concatenated 32-byte keys within a length-prefixed block - val adminBlock = reader.readOpaque2() + // Admin pubkeys: concatenated 32-byte keys within a VarInt-prefixed block + val adminBlock = reader.readOpaqueVarInt() val adminPubkeys = mutableListOf() var i = 0 while (i + 32 <= adminBlock.size) { @@ -224,23 +238,23 @@ data class MarmotGroupData( i += 32 } - // Relays: length-prefixed block of length-prefixed UTF-8 strings - val relaysBlock = reader.readOpaque2() + // Relays: VarInt-prefixed block of VarInt-prefixed UTF-8 strings + val relaysBlock = reader.readOpaqueVarInt() val relays = mutableListOf() val relayReader = TlsReader(relaysBlock) while (relayReader.hasRemaining) { - val relayBytes = relayReader.readOpaque2() + val relayBytes = relayReader.readOpaqueVarInt() 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 + val imageHash = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.toHexKey() else null + val imageKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null + val imageNonce = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null + val imageUploadKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null // v3+: disappearing_message_secs - val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0) + val disappearingBytes = if (reader.hasRemaining) reader.readOpaqueVarInt() else ByteArray(0) val disappearingMessageSecs: ULong? = when (disappearingBytes.size) { 0 -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt index fcc9284e8..9fdd5c6eb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt @@ -25,30 +25,29 @@ import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.mac.MacInstance -import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi /** * Handles EncryptedToken creation and decryption for Marmot push notifications (MIP-05). * - * EncryptedToken format (280 bytes total): - * ephemeral_pubkey(32) || nonce(12) || ciphertext(236 = 220 plaintext + 16 tag) + * EncryptedToken format (MUST be exactly 1084 bytes per MIP-05): + * ephemeral_pubkey(32) || nonce(12) || ciphertext(1040 = 1024 plaintext + 16 tag) * - * Token payload (220 bytes, padded): - * platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N) + * Token plaintext (MUST be exactly 1024 bytes per MIP-05): + * platform(1) || token_length(2 BE) || device_token(N) || random_padding(1024-3-N) * - * Key derivation: - * 1. ECDH: shared_point = ephemeral_privkey * server_pubkey - * 2. shared_x = sha256(shared_point) (x-coordinate as shared secret) - * 3. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x) - * 4. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32) - * 5. Encrypt padded payload with ChaCha20-Poly1305(key, nonce, payload, aad="") + * Key derivation (MIP-05 §"Key Derivation"): + * 1. ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x + * 2. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x) + * 3. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32) + * 4. Encrypt padded plaintext with ChaCha20-Poly1305(key, nonce, plaintext, aad="") * * Platform values: 0x01 = APNs, 0x02 = FCM */ object TokenEncryption { - private const val PADDED_PAYLOAD_SIZE = 220 + /** Token plaintext MUST be exactly 1024 bytes per MIP-05. */ + private const val PADDED_PAYLOAD_SIZE = 1024 private const val NONCE_SIZE = 12 private const val PUBKEY_SIZE = 32 private const val HEADER_SIZE = 3 // platform(1) + token_length(2) @@ -97,9 +96,9 @@ object TokenEncryption { // Extract the 32-byte x-only public key by dropping the SEC1 prefix byte val ephemeralPubKey = compressedPubKey.copyOfRange(1, 33) - // ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey) - val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey) - val sharedX = sha256(sharedPoint) + // ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x + // per MIP-05. Do NOT hash; HKDF-Extract will mix the salt. + val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey) // HKDF-Extract then Expand to get encryption key val encryptionKey = hkdfDeriveKey(sharedX) @@ -108,7 +107,7 @@ object TokenEncryption { val nonce = RandomInstance.bytes(NONCE_SIZE) val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey) - // Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236) + // Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(1040) val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE) ephemeralPubKey.copyInto(result, 0) nonce.copyInto(result, PUBKEY_SIZE) @@ -140,9 +139,9 @@ object TokenEncryption { val nonce = data.copyOfRange(PUBKEY_SIZE, PUBKEY_SIZE + NONCE_SIZE) val ciphertextWithTag = data.copyOfRange(PUBKEY_SIZE + NONCE_SIZE, data.size) - // ECDH: shared_x = sha256(server_privkey * ephemeral_pubkey) - val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey) - val sharedX = sha256(sharedPoint) + // ECDH: shared_x = secp256k1_ecdh(server_privkey, ephemeral_pubkey) — raw 32-byte x + // per MIP-05. + val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey) // Derive encryption key val encryptionKey = hkdfDeriveKey(sharedX) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt index 6eca9b6f8..5edbadf60 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/tags/TokenTag.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.ensure */ @Immutable data class TokenTagData( - /** Base64-encoded EncryptedToken (280 bytes when decoded) */ + /** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */ val encryptedToken: String, /** Hex-encoded notification server public key */ val serverPubKey: HexKey, @@ -52,8 +52,11 @@ class TokenTag { companion object { const val TAG_NAME = "token" - /** Expected decoded size of an EncryptedToken */ - const val ENCRYPTED_TOKEN_SIZE = 280 + /** + * Expected decoded size of an EncryptedToken per MIP-05: + * ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes. + */ + const val ENCRYPTED_TOKEN_SIZE = 1084 fun parse(tag: Array): TokenTagData? { ensure(tag.has(3) && tag[0] == TAG_NAME) { return null } 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 422fd0ad2..bdfec1984 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 @@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock * ## Cross-Implementation Notes * * This manager uses ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519). - * The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets - * for the current and previous epoch are kept for late-message decryption. + * The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 5, matching the + * `DEFAULT_EPOCH_LOOKBACK` used by the MDK reference implementation so that late- + * arriving MIP-03 GroupEvents (and MLS application messages) whose outer ChaCha20- + * Poly1305 key was derived from a prior epoch's exporter secret can still be + * decrypted after a Commit advances the group. * * Thread safety: All suspending mutation methods are guarded by a [Mutex] * to prevent concurrent state corruption. Non-suspending read methods @@ -662,9 +665,11 @@ class MlsGroupManager( /** * Number of past epochs to retain for late-arriving message decryption. - * MLS forward secrecy guarantees mean we want to limit this window. + * Matches MDK's `DEFAULT_EPOCH_LOOKBACK` so a message encrypted under + * the prior N epochs' exporter secrets can still be decrypted after a + * Commit advances the group. Capped for forward-secrecy reasons. */ - const val EPOCH_RETENTION_WINDOW = 2 + const val EPOCH_RETENTION_WINDOW = 5 /** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */ private const val REUSE_GUARD_LENGTH = 4 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt index f5c7be6c0..a323e7f5b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt @@ -115,27 +115,24 @@ class MarmotMipComplianceTest { @Test fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() { - // Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for - // name/description/admins/relays/images, then disappearing_message_secs - // = 8 bytes of zero (invalid). + // Hand-crafted TLS blob (MIP-01 QUIC VarInt length prefixes): + // uint16 version=3 | opaque group_id[32] | 8x empty VarInt(0) fields + // (name..image_upload_key) | disappearing_message_secs = VarInt(8) + 8 + // zero bytes (invalid per MIP-01). val header = ByteArray(2 + 32) { - // version + groupId when (it) { 0 -> 0 - 1 -> 3 - - // version=3 else -> 0 } } - // 8x opaque2 fields of length 0, each encoded as two zero bytes: + // 8 empty VarInt-prefixed opaque fields, each a single 0x00 byte: // name, description, admin_pubkeys, relays, image_hash, image_key, // image_nonce, image_upload_key - val zeroFields = ByteArray(8 * 2) // all zeros - // disappearing_message_secs opaque2 with 8 zero bytes - val disappearingField = ByteArray(2 + 8).also { it[1] = 8 } + val zeroFields = ByteArray(8) // all 0x00 + // disappearing_message_secs: VarInt(8) = 0x08, then 8 zero bytes + val disappearingField = ByteArray(1 + 8).also { it[0] = 0x08 } val blob = header + zeroFields + disappearingField // decodeTls catches any exception and returns null @@ -150,8 +147,9 @@ class MarmotMipComplianceTest { it[0] = 0 it[1] = 99 } - val zeroFields = ByteArray(8 * 2) // name..image_upload_key - val disappearingField = ByteArray(2) // zero-length + val zeroFields = ByteArray(8) // 8x VarInt(0) for name..image_upload_key + val disappearingField = ByteArray(1) // VarInt(0) — zero-length field + val blob = header + zeroFields + disappearingField assertNull(MarmotGroupData.decodeTls(blob)) From aea1dc53c3659c1cbe4686c16b030d7816f5e454 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:08:59 +0000 Subject: [PATCH 03/34] fix: show empty Marmot groups in Messages screen Previously ChatroomListKnownFeedFilter dropped any group whose newestMessage was null, so groups the user just created and groups received via Welcome events without any activity yet never appeared in the Messages list. Emit a stable placeholder Note (carrying the chatroom as a gatherer) per empty group, route it through the existing Marmot row path, and invalidate dmKnown on MarmotGroupList.groupListChanges so newly added or promoted groups rebuild the feed. --- .../loggedIn/AccountFeedContentStates.kt | 15 +++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 20 +++++++++++++----- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + .../model/marmotGroups/MarmotGroupChatroom.kt | 21 +++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ac6a5e30a..1dfbda89f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.dal.ShortsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.dal.VideoFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmarkFeedFilter import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class AccountFeedContentStates( val account: Account, @@ -100,6 +102,19 @@ class AccountFeedContentStates( val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) + init { + // Marmot group list changes (new group, group marked known, group + // metadata synced) don't flow through LocalCache.newEventBundles, so + // the additive update path can't see them. Force a full feed rebuild + // whenever the list changes so empty groups appear and placeholder + // rows get replaced by real messages. + scope.launch(Dispatchers.IO) { + account.marmotGroupList.groupListChanges.collect { + dmKnown.invalidateData() + } + } + } + suspend fun init() { notificationSummary.initializeSuspend() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 8e02c4945..cf2228a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -85,7 +85,11 @@ fun ChatroomHeaderCompose( accountViewModel: AccountViewModel, nav: INav, ) { - if (baseNote.event != null) { + val isEmptyMarmotPlaceholder = + baseNote.event == null && + baseNote.inGatherers?.any { it is MarmotGroupChatroom } == true + + if (baseNote.event != null || isEmptyMarmotPlaceholder) { ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav) } else { val hasEvent by observeNoteHasEvent(baseNote, accountViewModel) @@ -248,21 +252,27 @@ private fun MarmotGroupRoomCompose( accountViewModel: AccountViewModel, nav: INav, ) { - val authorName by observeUserName(lastMessage.author!!, accountViewModel) val displayName by chatroom.displayName.collectAsStateWithLifecycle() val unread by chatroom.unreadCount.collectAsStateWithLifecycle() + val author = lastMessage.author val noteEvent = lastMessage.event - val description = noteEvent?.content?.take(200) - val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + stringRes(R.string.marmot_group_no_messages_yet) + } + ChannelName( channelIdHex = chatroom.nostrGroupId, channelPicture = null, channelTitle = { modifier -> ChannelTitleWithLabelInfo(groupName, R.string.marmot_group, modifier) }, channelLastTime = lastMessage.createdAt(), - channelLastContent = "$authorName: $description", + channelLastContent = lastContent, hasNewMessages = unread > 0, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 932e3197f..e826250c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -79,7 +79,7 @@ class ChatroomListKnownFeedFilter( val marmotGroups = account.marmotGroupList.rooms.mapNotNull { _, chatroom -> - chatroom.newestMessage + chatroom.newestMessage ?: chatroom.placeholderNote() } return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 510936a64..b537cbcf6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -284,6 +284,7 @@ Public Chat MLS Group + No messages yet Public Chat Metadata Public chats are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index bf9ec65a0..d531e2778 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -59,6 +59,27 @@ class MarmotGroupChatroom( */ var ownerSentMessage: Boolean = false + // Synthetic note used by list views to represent the group when no + // messages have been received yet. Lazily created and kept stable so + // equality-based feed diffing treats it as the same row across refreshes. + private var cachedPlaceholder: Note? = null + + @Synchronized + fun placeholderNote(): Note { + val existing = cachedPlaceholder + if (existing != null) return existing + val created = + Note(placeholderIdHex(nostrGroupId)).apply { + addGatherer(this@MarmotGroupChatroom) + } + cachedPlaceholder = created + return created + } + + companion object { + fun placeholderIdHex(nostrGroupId: HexKey): HexKey = "marmot-empty-$nostrGroupId" + } + private var changesFlow: WeakReference>> = WeakReference(null) fun changesFlow(): MutableSharedFlow> { From 30a78c5922a23450a213e0ccbeae94fbe27e31aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:56:18 +0000 Subject: [PATCH 04/34] feat: NIP-72 compliance fixes + standalone Communities screen Quartz (NIP-72 compliance) - Fix CommunityDefinition image() builder to use the NIP-72 ImageTag (was wrongly pointing at the NIP-23 ImageTag, which prevented the builder from attaching `x` dimensions). - Fix CommunityPostApproval notifyAuthor() to emit a `p` tag for the post author as required by NIP-72 (was duplicating the `e` tag). - Add RelayTag.MARKER_AUTHOR/REQUESTS/APPROVALS constants. Amethyst - Communities screen - New top-level Communities screen with FeedFilterSpinner TopNav that supports the Mine option (filters to communities authored by the logged-in user). - New Route.Communities + drawer entry. - New CommunitiesFeedFilter (subclass of DiscoverCommunityFeedFilter using defaultCommunitiesFollowList + Mine handling). - New CommunitiesListFilterAssembler/SubAssembler with dedicated filterCommunitiesMine relay query. - Account.liveCommunitiesFollowLists + settings.defaultCommunitiesFollowList. - TopNavFilterState.communityRoutes (includes Mine). Amethyst - Create Community - Route.NewCommunity + FAB on the Communities screen. - NewCommunityModel + Material3 form: name, description, image URL, rules, moderator pubkeys, relay requests/approvals URLs. - Signs a kind 34550 CommunityDefinitionEvent and auto-follows it so it appears under "Mine". - Account.sendCommunityDefinition helper. Misc - Extracted parseTopFilterOrDefault helper in LocalPreferences to keep the existing inner load method under the JVM method-size limit. --- .../amethyst/LocalPreferences.kt | 34 +++- .../vitorpamplona/amethyst/model/Account.kt | 33 ++++ .../amethyst/model/AccountSettings.kt | 12 ++ .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../amethyst/ui/screen/TopNavFilterState.kt | 17 ++ .../loggedIn/AccountFeedContentStates.kt | 7 + .../communities/list/CommunitiesScreen.kt | 180 ++++++++++++++++++ .../communities/list/CommunitiesTopBar.kt | 70 +++++++ .../communities/list/NewCommunityButton.kt | 53 ++++++ .../list/dal/CommunitiesFeedFilter.kt | 125 ++++++++++++ .../CommunitiesListFilterAssembler.kt | 50 +++++ ...munitiesListFilterAssemblerSubscription.kt | 48 +++++ .../datasource/CommunitiesListSubAssembler.kt | 104 ++++++++++ .../list/datasource/FilterCommunitiesMine.kt | 51 +++++ .../newCommunity/NewCommunityModel.kt | 163 ++++++++++++++++ .../newCommunity/NewCommunityScreen.kt | 173 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 11 ++ .../approval/TagArrayBuilderExt.kt | 5 +- .../definition/TagArrayBuilderExt.kt | 8 +- .../definition/tags/RelayTag.kt | 4 + 24 files changed, 1154 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8e9f39da6..956685e7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -547,17 +547,17 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - before parsing events" } - val defaultHomeFollowList = async { parseOrNull(defaultHomeFollowListStr) ?: TopFilter.AllFollows } - val defaultStoriesFollowList = async { parseOrNull(defaultStoriesFollowListStr) ?: TopFilter.Global } - val defaultNotificationFollowList = async { parseOrNull(defaultNotificationFollowListStr) ?: TopFilter.Global } - val defaultDiscoveryFollowList = async { parseOrNull(defaultDiscoveryFollowListStr) ?: TopFilter.Global } + val defaultHomeFollowList = async { parseTopFilterOrDefault(defaultHomeFollowListStr, TopFilter.AllFollows) } + val defaultStoriesFollowList = async { parseTopFilterOrDefault(defaultStoriesFollowListStr, TopFilter.Global) } + val defaultNotificationFollowList = async { parseTopFilterOrDefault(defaultNotificationFollowListStr, TopFilter.Global) } + val defaultDiscoveryFollowList = async { parseTopFilterOrDefault(defaultDiscoveryFollowListStr, TopFilter.Global) } - val defaultPollsFollowList = async { parseOrNull(defaultPollsFollowListStr) ?: TopFilter.Global } - val defaultPicturesFollowList = async { parseOrNull(defaultPicturesFollowListStr) ?: TopFilter.Global } - val defaultProductsFollowList = async { parseOrNull(defaultProductsFollowListStr) ?: TopFilter.AroundMe } - val defaultShortsFollowList = async { parseOrNull(defaultShortsFollowListStr) ?: TopFilter.Global } - val defaultLongsFollowList = async { parseOrNull(defaultLongsFollowListStr) ?: TopFilter.Global } - val defaultArticlesFollowList = async { parseOrNull(defaultArticlesFollowListStr) ?: TopFilter.AllFollows } + val defaultPollsFollowList = async { parseTopFilterOrDefault(defaultPollsFollowListStr, TopFilter.Global) } + val defaultPicturesFollowList = async { parseTopFilterOrDefault(defaultPicturesFollowListStr, TopFilter.Global) } + val defaultProductsFollowList = async { parseTopFilterOrDefault(defaultProductsFollowListStr, TopFilter.AroundMe) } + val defaultShortsFollowList = async { parseTopFilterOrDefault(defaultShortsFollowListStr, TopFilter.Global) } + val defaultLongsFollowList = async { parseTopFilterOrDefault(defaultLongsFollowListStr, TopFilter.Global) } + val defaultArticlesFollowList = async { parseTopFilterOrDefault(defaultArticlesFollowListStr, TopFilter.AllFollows) } val nwcWalletsLoaded = async { @@ -672,6 +672,20 @@ object LocalPreferences { return result } + private fun parseTopFilterOrDefault( + value: String?, + default: TopFilter, + ): TopFilter { + if (value.isNullOrEmpty() || value == "null") return default + return try { + JsonMapper.fromJson(value) + } catch (e: Throwable) { + if (e is CancellationException) throw e + Log.w("LocalPreferences", "Error Decoding TopFilter from Preferences with value $value", e) + default + } + } + private inline fun parseOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a10e9fc2d..d9959c8d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -223,6 +223,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -481,6 +483,9 @@ class Account( val liveBadgesFollowLists: StateFlow = topNavFilterFlow(settings.defaultBadgesFollowList) val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow + val liveCommunitiesFollowLists: StateFlow = topNavFilterFlow(settings.defaultCommunitiesFollowList) + val liveCommunitiesFollowListsPerRelay = OutboxLoaderState(liveCommunitiesFollowLists, cache, scope).flow + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { @@ -1161,6 +1166,34 @@ class Account( client.publish(signedEvent, relays) } + suspend fun sendCommunityDefinition( + name: String, + description: String, + moderators: List, + image: String? = null, + rules: String? = null, + relays: List? = null, + dTag: String, + ): CommunityDefinitionEvent? { + if (!isWriteable()) return null + + val template = + CommunityDefinitionEvent.build( + name = name, + description = description, + moderators = moderators, + image = image, + rules = rules, + relays = relays, + dTag = dTag, + ) + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + client.publish(signedEvent, computeRelayListToBroadcast(signedEvent)) + return signedEvent + } + private fun loadCurrentAcceptedBadges(): List { val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey)) val newEvent = newNote?.event as? ProfileBadgesEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index e1d9ac714..118930bd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -191,6 +191,7 @@ class AccountSettings( val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), + val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, @@ -466,6 +467,17 @@ class AccountSettings( } } + fun changeDefaultCommunitiesFollowList(name: FeedDefinition) { + changeDefaultCommunitiesFollowList(name.code) + } + + fun changeDefaultCommunitiesFollowList(name: TopFilter) { + if (defaultCommunitiesFollowList.value != name) { + defaultCommunitiesFollowList.tryEmit(name) + saveAccountSettings() + } + } + fun changeDefaultPicturesFollowList(name: FeedDefinition) { changeDefaultPicturesFollowList(name.code) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 3d70480bd..14fe3f084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler @@ -101,6 +102,7 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) + val communitiesList = CommunitiesListFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -120,6 +122,7 @@ class RelaySubscriptionsCoordinator( articles, badges, profileBadges, + communitiesList, channelFinder, eventFinder, userFinder, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 41de5c811..9fc9e2ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -59,6 +59,7 @@ object ScrollStateKeys { const val POLLS_OPEN = "PollsOpenFeed" const val POLLS_CLOSED = "PollsClosedFeed" const val BADGES_SCREEN = "BadgesFeed" + const val COMMUNITIES_LIST = "CommunitiesListFeed" const val PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 90b1944a1..ba7c5b719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -93,6 +93,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -221,6 +223,8 @@ fun BuildNavigation( composable { DiscoverScreen(accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } + composableFromEnd { CommunitiesScreen(accountViewModel, nav) } + composableFromEnd { NewCommunityScreen(accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 000b5da78..9944fd5e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -58,6 +58,7 @@ import androidx.compose.material.icons.outlined.AccountBalanceWallet import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd +import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Photo @@ -619,6 +620,14 @@ fun ListContent( route = Route.Badges, ) + NavigationRow( + title = R.string.communities, + icon = Icons.Outlined.Groups, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Communities, + ) + NavigationRow( title = R.string.discover_marketplace, icon = Icons.Outlined.Storefront, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 96b1ad6a3..721b1e957 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -45,6 +45,10 @@ sealed class Route { @Serializable object Polls : Route() + @Serializable object Communities : Route() + + @Serializable object NewCommunity : Route() + @Serializable object Badges : Route() @Serializable object ProfileBadges : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index d008ad627..81cd62793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -275,6 +275,18 @@ class TopNavFilterState( ) } + private val _communityRoutes = + livePeopleListsFlow.transform { peopleLists -> + checkNotInMainThread() + emit( + listOf( + listOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow), + peopleLists, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -302,6 +314,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + val communityRoutes = + _communityRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index ac6a5e30a..feb411df1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFi import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal.CommunitiesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter @@ -84,6 +85,8 @@ class AccountFeedContentStates( val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache) + val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache) + val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache) @@ -130,6 +133,8 @@ class AccountFeedContentStates( badgesFeed.updateFeedWith(newNotes) + communitiesList.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -170,6 +175,8 @@ class AccountFeedContentStates( badgesFeed.deleteFromFeed(newNotes) + communitiesList.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt new file mode 100644 index 000000000..7071950a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesScreen.kt @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +@Composable +fun CommunitiesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + CommunitiesScreen( + feedContentState = accountViewModel.feedStates.communitiesList, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun CommunitiesScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedContentState) + WatchAccountForCommunitiesScreen(feedContentState, accountViewModel) + CommunitiesListFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + CommunitiesTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.Communities, accountViewModel) { route -> + if (route == Route.Communities) { + feedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + floatingButton = { + NewCommunityButton(nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(feedContentState, true) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.COMMUNITIES_LIST) { listState -> + RenderCommunitiesFeed( + feedContentState = feedContentState, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun RenderCommunitiesFeed( + feedContentState: FeedContentState, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + label = "RenderCommunitiesFeed", + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> FeedEmpty(feedContentState::invalidateData) + is FeedState.FeedError -> FeedError(state.errorMessage, feedContentState::invalidateData) + is FeedState.Loaded -> CommunitiesFeedLoaded(state, listState, accountViewModel, nav) + is FeedState.Loading -> LoadingFeed() + } + } +} + +@Composable +private fun CommunitiesFeedLoaded( + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item -> + Row(Modifier.fillMaxWidth().animateItem()) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = "CommunitiesListFeed", + modifier = Modifier.fillMaxWidth(), + forceEventKind = CommunityDefinitionEvent.KIND, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +@Composable +private fun WatchAccountForCommunitiesScreen( + feedContentState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveCommunitiesFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + feedContentState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt new file mode 100644 index 000000000..5c8957f05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/CommunitiesTopBar.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner +import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar +import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun CommunitiesTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultCommunitiesFollowList + .collectAsStateWithLifecycle() + + CommunitiesTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultCommunitiesFollowList, + ) + } +} + +@Composable +private fun CommunitiesTopNavFilterBar( + followListsModel: TopNavFilterState, + listName: TopFilter, + accountViewModel: AccountViewModel, + onChange: (FeedDefinition) -> Unit, +) { + val allLists by followListsModel.communityRoutes.collectAsStateWithLifecycle() + + FeedFilterSpinner( + placeholderCode = listName, + explainer = stringRes(R.string.select_list_to_filter), + options = allLists, + onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt new file mode 100644 index 000000000..a16490b90 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/NewCommunityButton.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewCommunityButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.NewCommunity) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringRes(id = R.string.new_community), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt new file mode 100644 index 000000000..3b5decf1a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/dal/CommunitiesFeedFilter.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.filterIntoSet +import com.vitorpamplona.amethyst.model.mapNotNullIntoSet +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +class CommunitiesFeedFilter( + account: Account, +) : DiscoverCommunityFeedFilter(account) { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-communities-" + followList().code + + override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value + + private fun myPubkey(): String = account.userProfile().pubkeyHex + + override fun feed(): List { + if (followList() == TopFilter.Mine) { + val me = myPubkey() + val notes = + LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note -> + val noteEvent = note.event + noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me + } + return sort(notes) + } + + val filterParams = + FilterByListParams.create( + followLists = account.liveCommunitiesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + val notes = + LocalCache.addressables.mapNotNullIntoSet(CommunityDefinitionEvent.KIND) { key, note -> + val noteEvent = note.event + if (noteEvent == null && shouldInclude(key, filterParams, note.relays)) { + note + } else if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) { + note + } else { + null + } + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + override fun innerApplyFilter(collection: Collection): Set { + if (followList() == TopFilter.Mine) { + val me = myPubkey() + return collection + .filterTo(HashSet()) { + val noteEvent = it.event + noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me + } + } + + val filterParams = + FilterByListParams.create( + followLists = account.liveCommunitiesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + return collection + .mapNotNull { note -> + val noteEvent = note.event + if (noteEvent is CommunityDefinitionEvent && filterParams.match(noteEvent, note.relays)) { + listOf(note) + } else if (noteEvent is CommunityPostApprovalEvent) { + noteEvent.communityAddresses().mapNotNull { + val definitionNote = LocalCache.getOrCreateAddressableNote(it) + val definitionEvent = definitionNote.event + + if (definitionEvent == null && shouldInclude(it, filterParams, definitionNote.relays)) { + definitionNote + } else if (definitionEvent is CommunityDefinitionEvent && filterParams.match(definitionEvent, definitionNote.relays)) { + definitionNote + } else { + null + } + } + } else { + null + } + }.flatten() + .toSet() + } + + private fun shouldInclude( + aTag: Address?, + params: FilterByListParams, + comingFrom: List = emptyList(), + ) = aTag != null && aTag.kind == CommunityDefinitionEvent.KIND && params.match(aTag, comingFrom) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt new file mode 100644 index 000000000..1ac72e047 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class CommunitiesListQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class CommunitiesListFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + CommunitiesListSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt new file mode 100644 index 000000000..a1db05935 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun CommunitiesListFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + CommunitiesListFilterAssemblerSubscription( + accountViewModel.dataSources().communitiesList, + accountViewModel, + ) +} + +@Composable +fun CommunitiesListFilterAssemblerSubscription( + dataSource: CommunitiesListFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + CommunitiesListQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt new file mode 100644 index 000000000..61cef800a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/CommunitiesListSubAssembler.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.makeCommunitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +class CommunitiesListSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: CommunitiesListQueryState, + since: SincePerRelayMap?, + ): List { + val listName = key.listName() + val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled() + + return if (listName == TopFilter.Mine) { + val outbox = key.account.outboxRelays.flow.value + filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since) + } else { + makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince) + } + } + + override fun user(key: CommunitiesListQueryState) = key.account.userProfile() + + override fun list(key: CommunitiesListQueryState) = key.listName() + + fun CommunitiesListQueryState.listNameFlow() = account.settings.defaultCommunitiesFollowList + + fun CommunitiesListQueryState.listName() = listNameFlow().value + + fun CommunitiesListQueryState.followsPerRelayFlow() = account.liveCommunitiesFollowListsPerRelay + + fun CommunitiesListQueryState.followsPerRelay() = followsPerRelayFlow().value + + val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: CommunitiesListQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.communitiesList.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt new file mode 100644 index 000000000..12593aae0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/list/datasource/FilterCommunitiesMine.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +private const val COMMUNITIES_MINE_LIMIT = 300 + +fun filterCommunitiesMine( + pubkey: HexKey, + relays: Set, + since: SincePerRelayMap?, +): List { + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityDefinitionEvent.KIND), + authors = authors, + limit = COMMUNITIES_MINE_LIMIT, + since = since?.get(relay)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt new file mode 100644 index 000000000..bf6f749bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Stable +class NewCommunityModel : ViewModel() { + var account: Account? = null + + var isPublishing by mutableStateOf(false) + + var name by mutableStateOf("") + var description by mutableStateOf("") + var imageUrl by mutableStateOf("") + var rules by mutableStateOf("") + var moderatorsText by mutableStateOf("") + var relayRequestsUrl by mutableStateOf("") + var relayApprovalsUrl by mutableStateOf("") + + fun init(account: Account) { + if (this.account == account) return + this.account = account + } + + fun canPost(): Boolean = + !isPublishing && + name.isNotBlank() && + description.isNotBlank() + + @OptIn(ExperimentalUuidApi::class) + fun publish( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) = try { + publishUnsafe(context, onSuccess, onError) + } catch (e: SignerExceptions.ReadOnlyException) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + } + + @OptIn(ExperimentalUuidApi::class) + private fun publishUnsafe( + context: Context, + onSuccess: () -> Unit, + onError: (String, String) -> Unit, + ) { + val myAccount = account ?: return + + viewModelScope.launch(Dispatchers.IO) { + isPublishing = true + try { + val moderatorTags = parseModeratorTags(myAccount) + val relayTags = parseRelayTags() + + val definition = + myAccount.sendCommunityDefinition( + name = name.trim(), + description = description.trim(), + moderators = moderatorTags, + image = imageUrl.trim().ifBlank { null }, + rules = rules.trim().ifBlank { null }, + relays = relayTags.ifEmpty { null }, + dTag = Uuid.random().toString(), + ) + + if (definition == null) { + onError( + stringRes(context, R.string.read_only_user), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events), + ) + return@launch + } + + // Also follow the community so it appears on the user's list. + val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) + myAccount.follow(communityNote) + + reset() + onSuccess() + } finally { + isPublishing = false + } + } + } + + private fun parseModeratorTags(account: Account): List { + val hexes = + moderatorsText + .lineSequence() + .map { it.trim() } + .filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } + .map { it.lowercase() } + .toSet() + + val withOwner = hexes + account.signer.pubKey + + return withOwner.map { ModeratorTag(it, null, "moderator") } + } + + private fun parseRelayTags(): List { + val tags = mutableListOf() + relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { + RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> + tags += RelayTag(url, RelayTag.MARKER_REQUESTS) + } + } + relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let { + RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> + tags += RelayTag(url, RelayTag.MARKER_APPROVALS) + } + } + return tags + } + + fun reset() { + name = "" + description = "" + imageUrl = "" + rules = "" + moderatorsText = "" + relayRequestsUrl = "" + relayApprovalsUrl = "" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt new file mode 100644 index 000000000..fe4055a4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewCommunityScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val model: NewCommunityModel = viewModel() + + LaunchedEffect(accountViewModel.account) { + model.init(accountViewModel.account) + } + + val context = LocalContext.current + var errorTitle by remember { mutableStateOf(null) } + var errorBody by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.new_community), + popBack = { nav.popBack() }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp, vertical = 12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = model.name, + onValueChange = { model.name = it }, + label = { Text(stringRes(R.string.new_community_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.description, + onValueChange = { model.description = it }, + label = { Text(stringRes(R.string.new_community_description)) }, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.imageUrl, + onValueChange = { model.imageUrl = it }, + label = { Text(stringRes(R.string.new_community_image_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.rules, + onValueChange = { model.rules = it }, + label = { Text(stringRes(R.string.new_community_rules)) }, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.moderatorsText, + onValueChange = { model.moderatorsText = it }, + label = { Text(stringRes(R.string.new_community_moderators)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.relayRequestsUrl, + onValueChange = { model.relayRequestsUrl = it }, + label = { Text(stringRes(R.string.new_community_relay_requests)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = model.relayApprovalsUrl, + onValueChange = { model.relayApprovalsUrl = it }, + label = { Text(stringRes(R.string.new_community_relay_approvals)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + errorTitle?.let { title -> + Text( + text = title, + fontWeight = FontWeight.Bold, + ) + errorBody?.let { body -> + Text(text = body) + } + } + + Button( + onClick = { + errorTitle = null + errorBody = null + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = { title, body -> + errorTitle = title + errorBody = body + }, + ) + }, + enabled = model.canPost(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringRes(R.string.new_community_publish)) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 510936a64..6cc43d0fc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -420,6 +420,17 @@ Open Closed Badges + Communities + New Community + Community name + Description + Cover image URL (optional) + Rules (optional) + Moderators (one pubkey per line, optional) + Relay for requests (optional) + Relay for approvals (optional) + Publish + No communities match this filter. Received Mine Awarded diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt index 3b55ac56b..ecedc922c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/TagArrayBuilderExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent fun TagArrayBuilder.community(event: EventHintBundle) = add(event.toATag().toATagArray()) @@ -37,6 +38,4 @@ fun TagArrayBuilder.approved(event: EventHintBundle< } } -fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) { - add(event.toETagArray()) -} +fun TagArrayBuilder.notifyAuthor(event: EventHintBundle) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt index 0fa5adefe..c5d5ed0d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/TagArrayBuilderExt.kt @@ -21,18 +21,22 @@ package com.vitorpamplona.quartz.nip72ModCommunities.definition import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag fun TagArrayBuilder.name(name: String) = addUnique(NameTag.assemble(name)) fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) -fun TagArrayBuilder.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl)) +fun TagArrayBuilder.image( + webUrl: String, + dimensions: DimensionTag? = null, +) = addUnique(ImageTag.assemble(webUrl, dimensions)) fun TagArrayBuilder.rules(rules: String) = addUnique(RulesTag.assemble(rules)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt index 6398f4b55..bda3d1141 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/definition/tags/RelayTag.kt @@ -35,6 +35,10 @@ class RelayTag( companion object { const val TAG_NAME = "relay" + const val MARKER_AUTHOR = "author" + const val MARKER_REQUESTS = "requests" + const val MARKER_APPROVALS = "approvals" + fun parse(tag: Array): RelayTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } From 77c3634745a7fa39c3bcb4924ca01b9de0368808 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 18:02:12 +0000 Subject: [PATCH 05/34] fix: tighten Marmot MIP-00/01/02/05 + RFC 9420 PublicMessage framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the earlier MIP-spec alignment commit, addressing the remaining audit items: - MIP-00 (KeyPackageUtils.isValid): now performs every strict tag-level check that MDK does — d tag is exactly 64 hex chars, mls_protocol_version is exactly "1.0", mls_ciphersuite is exactly "0x0001", mls_extensions contains both 0xf2ee + 0x000a, mls_proposals contains 0x000a, encoding is "base64", content non-empty, i tag present. Adds `isCryptographicallyValid` for deep checks: i tag matches the computed KeyPackageRef (RFC 9420 §5.2), Basic credential identity equals the event's pubkey, KeyPackage signature verifies. - MIP-01 (MarmotGroupData): encoder now omits the v3-only `disappearing_message_secs` field when version < 3, so v2 output matches MDK's `tls_codec` 0.4 byte-for-byte. Adds byte-level fixture tests pinned to the Rust reference (encode + decode in both directions). - MIP-02 (WelcomeGiftWrap): replaces the redundant sign-then-rumorize step with `RumorAssembler.assembleRumor`, producing a true unsigned rumor as NIP-59 requires (no wasted secp256k1 signature). - MIP-05 kind 449 (TokenRemovalEvent.build): no longer accepts a tag initializer. Per the MIP-05 spec the token-removal event MUST have empty tags; allowing arbitrary caller-supplied tags risks metadata leakage and rejection by strict validators (e.g. the MDK reference). - RFC 9420 §6 PublicMessage framing: encoder/decoder now branch on `contentType`. Application messages keep the `opaque` length prefix; Proposal/Commit bodies are emitted/parsed as their typed structs (no outer length prefix), per the MLS RFC. Previously encoder used `putBytes` raw and decoder used `readOpaqueVarInt`, so neither could roundtrip and Proposal/Commit PublicMessages from other MLS clients would mis-parse. - KeyPackageUtilsTest fixtures: switch tiny "0"/"1"/"lr" slot ids to realistic 64-char hex slot ids, since `isValid` now enforces the MIP-00 d-tag format. --- .../mip00KeyPackages/KeyPackageUtils.kt | 96 ++++++++++++++- .../marmot/mip01Groups/MarmotGroupData.kt | 28 +++-- .../marmot/mip02Welcome/WelcomeGiftWrap.kt | 15 ++- .../TokenRemovalEvent.kt | 14 +-- .../quartz/marmot/mls/framing/MlsMessage.kt | 41 ++++++- .../quartz/marmot/KeyPackageUtilsTest.kt | 40 ++++--- .../quartz/marmot/MarmotMipComplianceTest.kt | 110 ++++++++++++++++++ 7 files changed, 296 insertions(+), 48 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt index 8d9e7a790..fb961e36e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -21,12 +21,19 @@ package com.vitorpamplona.quartz.marmot.mip00KeyPackages import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage +import com.vitorpamplona.quartz.marmot.mls.tree.Credential import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi /** * Utility functions for KeyPackage lifecycle management (MIP-00). @@ -88,12 +95,91 @@ object KeyPackageUtils { /** * Validates a KeyPackage event has required fields and proper encoding. + * + * Performs the same strict tag-level MIP-00 checks used by MDK so that + * malformed or adversarial events are rejected at parse time: + * - `d` tag is exactly 64 lowercase hex characters (32-byte slot ID) + * - `mls_protocol_version` is exactly "1.0" + * - `mls_ciphersuite` is exactly "0x0001" + * - `mls_extensions` contains both "0xf2ee" (NostrGroupData) and + * "0x000a" (LastResort) + * - `mls_proposals` contains "0x000a" (SelfRemove) + * - `encoding` is "base64" and content is non-empty + * - `i` (keyPackageRef) tag is non-empty + * + * For deep cryptographic checks (KeyPackageRef hash match, credential + * identity == event.pubkey) call [isCryptographicallyValid]. */ - fun isValid(event: KeyPackageEvent): Boolean = - event.encoding() == EncodingTag.BASE64 && - event.content.isNotEmpty() && - !event.keyPackageRef().isNullOrEmpty() && - !event.mlsCiphersuite().isNullOrEmpty() + fun isValid(event: KeyPackageEvent): Boolean { + // d tag: exactly 64 hex chars per MIP-00 + val dTag = event.dTag() + if (dTag.length != 64 || !dTag.all { it.isHexChar() }) return false + + // mls_protocol_version == "1.0" + if (event.mlsProtocolVersion() != MlsProtocolVersionTag.CURRENT_VERSION) return false + + // mls_ciphersuite == "0x0001" + if (event.mlsCiphersuite() != MlsCiphersuiteTag.DEFAULT_CIPHERSUITE) return false + + // mls_extensions MUST include both 0xf2ee and 0x000a + val extensions = event.mlsExtensions()?.map { it.lowercase() }?.toSet() ?: return false + if (!extensions.contains("0xf2ee") || !extensions.contains("0x000a")) return false + + // mls_proposals MUST include 0x000a (SelfRemove) + val proposals = event.mlsProposals()?.map { it.lowercase() }?.toSet() ?: return false + if (!proposals.contains("0x000a")) return false + + // encoding MUST be base64 and content non-empty + if (event.encoding() != EncodingTag.BASE64) return false + if (event.content.isEmpty()) return false + + // i (KeyPackageRef) tag MUST be present + if (event.keyPackageRef().isNullOrEmpty()) return false + + return true + } + + /** + * Deep MIP-00 validation: decodes the KeyPackage content and verifies: + * - `i` tag matches the computed `KeyPackageRef` (RFC 9420 §5.2) + * - Credential identity (BasicCredential) equals the event's `pubkey` + * (32-byte x-only Nostr pubkey) + * - KeyPackage signature over KeyPackageTBS is valid + * + * Returns true only if [isValid] also holds and every cryptographic check + * passes. Requires [isValid] to be true as a precondition — it is called + * internally. + */ + @OptIn(ExperimentalEncodingApi::class) + fun isCryptographicallyValid(event: KeyPackageEvent): Boolean { + if (!isValid(event)) return false + + val iTag = event.keyPackageRef() ?: return false + val keyPackage = + try { + val bytes = Base64.decode(event.content) + MlsKeyPackage.decodeTls(TlsReader(bytes)) + } catch (_: Throwable) { + return false + } + + // i tag MUST equal the computed KeyPackageRef + if (keyPackage.reference().toHexKey() != iTag.lowercase()) return false + + // Credential identity MUST equal the event's pubkey (32-byte x-only). + // MIP-00 requires BasicCredential with the raw 32-byte Nostr pubkey. + val credential = keyPackage.leafNode.credential + if (credential !is Credential.Basic) return false + if (credential.identity.size != 32) return false + if (credential.identity.toHexKey().lowercase() != event.pubKey.lowercase()) return false + + // KeyPackage signature MUST verify against the LeafNode's signatureKey. + if (!keyPackage.verifySignature()) return false + + return true + } + + private fun Char.isHexChar(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' /** * Builds a rotated KeyPackage for the same d-tag slot. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt index 534bebd8c..3d89264be 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MarmotGroupData.kt @@ -150,18 +150,22 @@ data class MarmotGroupData( writer.putOpaqueVarInt(imageNonce ?: ByteArray(0)) writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0)) - // v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs) - val disappearingBytes = - disappearingMessageSecs?.let { secs -> - val out = ByteArray(8) - var v = secs.toLong() - for (i in 7 downTo 0) { - out[i] = (v and 0xFF).toByte() - v = v ushr 8 - } - out - } ?: ByteArray(0) - writer.putOpaqueVarInt(disappearingBytes) + // v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs). + // Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps + // the wire format byte-for-byte compatible with older implementations (MDK v2). + if (version >= 3) { + val disappearingBytes = + disappearingMessageSecs?.let { secs -> + val out = ByteArray(8) + var v = secs.toLong() + for (i in 7 downTo 0) { + out[i] = (v and 0xFF).toByte() + v = v ushr 8 + } + out + } ?: ByteArray(0) + writer.putOpaqueVarInt(disappearingBytes) + } return writer.toByteArray() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt index da717ab1e..029b23479 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -60,7 +61,9 @@ object WelcomeGiftWrap { nostrGroupId: HexKey? = null, createdAt: Long = TimeUtils.now(), ): GiftWrapEvent { - // Step 1: Build the WelcomeEvent template and sign it + // Step 1: Build the WelcomeEvent template directly as an unsigned rumor. + // Per NIP-59 rumors MUST have an empty sig field, so we skip the outer + // signature entirely and let the SealedRumorEvent carry authorship. val welcomeTemplate = WelcomeEvent.build( welcomeBase64 = welcomeBase64, @@ -69,10 +72,14 @@ object WelcomeGiftWrap { nostrGroupId = nostrGroupId, createdAt = createdAt, ) - val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate) + val welcomeRumor: WelcomeEvent = + RumorAssembler.assembleRumor( + pubKey = signer.pubKey, + ev = welcomeTemplate, + ) - // Step 2: Create a Rumor from the signed event and seal it (kind:13) - val rumor = Rumor.create(welcomeEvent) + // Step 2: Create a Rumor from the unsigned event and seal it (kind:13) + val rumor = Rumor.create(welcomeRumor) val sealedRumor = SealedRumorEvent.create( rumor = rumor, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt index 8cad85a6e..1d51545f1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenRemovalEvent.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -33,8 +32,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils * Unsigned application message sent inside a GroupEvent (kind:445) when a device * leaves a group or wants to disable push notifications. * - * Has no tags — the MLS leaf index is implicit from the MLS sender identity. - * Receiving clients MUST remove the token for the identified leaf. + * Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit + * from the MLS sender identity; receiving clients MUST remove the token for + * the identified leaf. Adding extra tags could leak metadata or be rejected + * by strict MIP-05 validators (e.g. the MDK reference). * * MUST remain unsigned (no sig field) per MIP-03 security requirements. */ @@ -50,11 +51,6 @@ class TokenRemovalEvent( companion object { const val KIND = 449 - fun build( - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - initializer() - } + fun build(createdAt: Long = TimeUtils.now()) = eventTemplate(KIND, "", createdAt) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt index 246f05910..db6b9e638 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/framing/MlsMessage.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.marmot.mls.framing import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.marmot.mls.messages.Commit +import com.vitorpamplona.quartz.marmot.mls.messages.Proposal /** * MLS MLSMessage (RFC 9420 Section 6). @@ -161,7 +163,17 @@ data class PublicMessage( encodeSender(writer, sender) writer.putOpaqueVarInt(authenticatedData) writer.putUint8(contentType.value) - writer.putBytes(content) + + // RFC 9420 §6 FramedContent.content is a type-dependent body: + // case application: opaque application_data + // case proposal: Proposal proposal (struct, no outer length prefix) + // case commit: Commit commit (struct, no outer length prefix) + // `content` holds the already-serialized body for proposal/commit, and + // the raw application bytes for application. + when (contentType) { + ContentType.APPLICATION -> writer.putOpaqueVarInt(content) + ContentType.PROPOSAL, ContentType.COMMIT -> writer.putBytes(content) + } // FramedContentAuthData writer.putOpaqueVarInt(signature) @@ -191,9 +203,30 @@ data class PublicMessage( val authenticatedData = reader.readOpaqueVarInt() val contentType = ContentType.fromValue(reader.readUint8()) - // Content is variable based on content_type, read remaining content - // For now, read as opaque - val content = reader.readOpaqueVarInt() + // RFC 9420 §6 FramedContent.content body varies by content_type. + // For PROPOSAL/COMMIT we decode the inner struct to advance the reader + // and then re-serialize back to bytes so the invariant + // "content holds the serialized body" holds for all variants. + val content: ByteArray = + when (contentType) { + ContentType.APPLICATION -> { + reader.readOpaqueVarInt() + } + + ContentType.PROPOSAL -> { + val proposal = Proposal.decodeTls(reader) + val w = TlsWriter() + proposal.encodeTls(w) + w.toByteArray() + } + + ContentType.COMMIT -> { + val commit = Commit.decodeTls(reader) + val w = TlsWriter() + commit.encodeTls(w) + w.toByteArray() + } + } val signature = reader.readOpaqueVarInt() val confirmationTag = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt index e7d7e0e59..9bbe5a861 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/KeyPackageUtilsTest.kt @@ -37,8 +37,20 @@ class KeyPackageUtilsTest { private val testPubKey = "a".repeat(64) private val testRef = "b".repeat(64) + /** + * Per MIP-00 the `d` tag MUST be 64 lowercase hex characters + * (a random 32-byte slot ID). The strict [KeyPackageUtils.isValid] enforces + * this on the parse side, so test fixtures need realistic 64-char hex + * slot IDs even when we only care about the selection logic. + */ + private fun slot(label: Int): String = "0".repeat(63) + label.toString(16) + + private val slot0 = slot(0) + private val slot1 = slot(1) + private val slotLastResort = "f".repeat(64) + private fun makeKeyPackageEvent( - dTag: String = "0", + dTag: String = slot0, createdAt: Long = 1000, encoding: String = "base64", ciphersuite: String = "0x0001", @@ -105,8 +117,8 @@ class KeyPackageUtilsTest { @Test fun testSelectBest_PrefersNewest() { - val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000) - val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000) + val old = makeKeyPackageEvent(dTag = slot0, createdAt = 1000) + val newer = makeKeyPackageEvent(dTag = slot1, createdAt = 2000) val best = KeyPackageUtils.selectBest(listOf(old, newer)) assertNotNull(best) @@ -115,33 +127,33 @@ class KeyPackageUtilsTest { @Test fun testSelectBest_PrefersNonLastResort() { - val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000) - val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000) + val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000) + val regular = makeKeyPackageEvent(dTag = slot0, createdAt = 1000) // Even though lastResort is newer, regular is preferred - val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr") + val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = slotLastResort) assertNotNull(best) - assertEquals("0", best.dTag()) + assertEquals(slot0, best.dTag()) } @Test fun testSelectBest_FallsBackToLastResort() { - val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000) + val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000) // Only last-resort available - val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr") + val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = slotLastResort) assertNotNull(best) - assertEquals("lr", best.dTag()) + assertEquals(slotLastResort, best.dTag()) } @Test fun testSelectBest_FiltersOutInvalid() { - val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw") - val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500) + val invalid = makeKeyPackageEvent(dTag = slot0, encoding = "raw") + val valid = makeKeyPackageEvent(dTag = slot1, createdAt = 500) val best = KeyPackageUtils.selectBest(listOf(invalid, valid)) assertNotNull(best) - assertEquals("1", best.dTag()) + assertEquals(slot1, best.dTag()) } @Test @@ -159,7 +171,7 @@ class KeyPackageUtilsTest { val template = KeyPackageUtils.buildRotation( newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=", - dTagSlot = "0", + dTagSlot = slot0, newKeyPackageRef = testRef, relays = emptyList(), ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt index a323e7f5b..201f79e63 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotMipComplianceTest.kt @@ -155,6 +155,116 @@ class MarmotMipComplianceTest { assertNull(MarmotGroupData.decodeTls(blob)) } + @Test + fun marmotGroupData_rejectsDuplicateAdminPubkeys() { + // MIP-01: admin_pubkeys MUST NOT contain duplicate keys. + assertFailsWith { + MarmotGroupData( + nostrGroupId = groupId32, + adminPubkeys = listOf(adminPubkey, adminPubkey), + ) + } + } + + // --- MIP-01 byte-level interop fixtures (MDK reference) ---------------- + // + // These fixtures were produced by serializing the identical struct via the + // Rust `tls_codec` 0.4 crate used by MDK (see commit message for the + // generator). They pin Amethyst's v2 encoder output byte-for-byte against + // the MDK reference, so any future regression in VarInt framing surfaces + // immediately. + // + // Fixtures are v2 (no `disappearing_message_secs` field) because MDK's + // current `CURRENT_VERSION = 2` reference has no v3 support yet. + + private fun mdkFixtureA(): ByteArray = + ( + // version=2 + 32 bytes of group_id (all zero) + "0002" + + "00".repeat(32) + + // name=empty, description=empty (VarInt(0) = single 0x00) + "0000" + + // admin_pubkeys: VarInt(32) = 0x20, then one 32-byte key of 0xAA + "20" + "aa".repeat(32) + + // relays: outer VarInt(21) = 0x15; inner VarInt(20) = 0x14 + + // "wss://relay.example/" (20 bytes) + "15" + "14" + "7773733a2f2f72656c61792e6578616d706c652f" + + // image_hash, image_key, image_nonce, image_upload_key — all empty + "00000000" + ).hexToByteArray() + + private fun mdkFixtureB(): ByteArray = + ( + "0002" + + "11".repeat(32) + + // name: VarInt(8)=0x08 + "Amethyst" + "08" + "416d657468797374" + + // description: VarInt(10)=0x0a + "Test group" + "0a" + "546573742067726f7570" + + // admin_pubkeys: outer VarInt(64) — 64 = 0x40, two-byte VarInt + // prefix "40 40" (high bits 01, value 0x0040) + 2×32 bytes + "4040" + "bb".repeat(32) + "cc".repeat(32) + + // relays outer VarInt(44) = 0x2c, then two inner relays each + // VarInt(21) + 21-byte URL + "2c" + + "15" + "7773733a2f2f72656c6179312e6578616d706c652f" + + "15" + "7773733a2f2f72656c6179322e6578616d706c652f" + + // image_* all empty + "00000000" + ).hexToByteArray() + + @Test + fun marmotGroupData_encodesFixtureAByteForByteVsMdk() { + // Encode an Amethyst MarmotGroupData with the same inputs and assert the + // bytes match MDK's tls_codec 0.4 output exactly. + val data = + MarmotGroupData( + version = 2, + nostrGroupId = "00".repeat(32), + name = "", + description = "", + adminPubkeys = listOf("aa".repeat(32)), + relays = listOf("wss://relay.example/"), + ) + assertContentEquals(mdkFixtureA(), data.encodeTls()) + } + + @Test + fun marmotGroupData_encodesFixtureBByteForByteVsMdk() { + val data = + MarmotGroupData( + version = 2, + nostrGroupId = "11".repeat(32), + name = "Amethyst", + description = "Test group", + adminPubkeys = listOf("bb".repeat(32), "cc".repeat(32)), + relays = listOf("wss://relay1.example/", "wss://relay2.example/"), + ) + assertContentEquals(mdkFixtureB(), data.encodeTls()) + } + + @Test + fun marmotGroupData_decodesMdkFixtureA() { + val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureA())) + assertEquals(2, decoded.version) + assertEquals("00".repeat(32), decoded.nostrGroupId) + assertEquals("", decoded.name) + assertEquals("", decoded.description) + assertEquals(listOf("aa".repeat(32)), decoded.adminPubkeys) + assertEquals(listOf("wss://relay.example/"), decoded.relays) + assertNull(decoded.disappearingMessageSecs) + } + + @Test + fun marmotGroupData_decodesMdkFixtureB() { + val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureB())) + assertEquals(2, decoded.version) + assertEquals("Amethyst", decoded.name) + assertEquals("Test group", decoded.description) + assertEquals(listOf("bb".repeat(32), "cc".repeat(32)), decoded.adminPubkeys) + assertEquals(listOf("wss://relay1.example/", "wss://relay2.example/"), decoded.relays) + } + @Test fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() { val seed = "11".repeat(32).hexToByteArray() From 830e410a7929d65feafc693617f0c0ca9f2f1138 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 20 Apr 2026 20:45:13 +0200 Subject: [PATCH 06/34] feat(video): default feed/PiP to lowest HLS resolution, fullscreen to auto --- .../playback/composable/VideoViewInner.kt | 6 ++ .../controls/InitialVideoQualitySelector.kt | 96 +++++++++++++++++++ .../controls/VideoQualityAvailability.kt | 50 ++++++++-- .../composable/controls/VideoQualityButton.kt | 41 +++----- .../service/playback/pip/PipVideoActivity.kt | 7 ++ .../ui/components/ZoomableContentDialog.kt | 2 + 6 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 99f0a5556..c8b1d009b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -50,6 +51,7 @@ fun VideoViewInner( controllerVisible: MutableState = mutableStateOf(false), onZoom: (() -> Unit)? = null, hasBlurhash: Boolean = false, + isFullscreen: Boolean = false, accountViewModel: AccountViewModel, ) { // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes @@ -71,6 +73,10 @@ fun VideoViewInner( mediaItem = mediaItem, muted = muted, ) { controller -> + ApplyInitialVideoQuality( + player = controller.controller, + isFullscreen = isFullscreen, + ) VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) RenderVideoPlayer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt new file mode 100644 index 000000000..994d029f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.media3.common.Player +import androidx.media3.common.Tracks + +/** + * Applies a default video quality when tracks become available on the given player. + * + * - In feed context (`isFullscreen = false`): locks to the lowest-resolution rendition to save + * bandwidth. The user can still manually pick any quality (or "Auto") via the quality button. + * - In fullscreen context (`isFullscreen = true`): clears any video override so the player uses + * adaptive bitrate selection (Auto). + * + * The initial selection is applied once per media item. If the user later changes the quality + * manually, or swaps to a different media item, the new choice wins — we don't reapply for the + * same media id. Selections intentionally don't persist across composable lifecycles, so opening + * a feed video in fullscreen starts with Auto and returning to the feed starts with lowest again. + */ +@Composable +fun ApplyInitialVideoQuality( + player: Player, + isFullscreen: Boolean, +) { + // Tracks the media id we've already initialized so we don't fight user overrides after the + // first application. Scoped to this composable instance so fullscreen <-> feed transitions + // reset the choice as required (they're separate VideoViewInner instances with separate + // players, so isFullscreen never flips on a given instance). + val appliedForMediaId = remember(player) { arrayOf(null) } + + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + applyInitialQuality(player, tracks, isFullscreen, appliedForMediaId) + } + } + + // Tracks might already be available by the time we attach the listener. + applyInitialQuality(player, player.currentTracks, isFullscreen, appliedForMediaId) + player.addListener(listener) + onDispose { player.removeListener(listener) } + } +} + +private fun applyInitialQuality( + player: Player, + tracks: Tracks, + isFullscreen: Boolean, + appliedForMediaId: Array, +) { + val mediaId = player.currentMediaItem?.mediaId ?: return + if (appliedForMediaId[0] == mediaId) return + + val videoGroup = getVideoTrackGroup(tracks) ?: return + // No point forcing a choice when there's only one rendition. + if (videoGroup.length <= 1) { + appliedForMediaId[0] = mediaId + return + } + + if (isFullscreen) { + // Ensure adaptive selection is active by removing any pre-existing video override. + if (hasVideoOverride(player)) { + clearVideoOverride(player) + } + } else { + val lowestIndex = findLowestResolutionTrackIndex(videoGroup) + if (lowestIndex != null) { + selectVideoTrack(player, videoGroup, lowestIndex) + } + } + appliedForMediaId[0] = mediaId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt index 25581490c..28e2bf2a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt @@ -20,22 +20,52 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import androidx.annotation.OptIn import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } -// Returns the "Xp" value for the currently selected video track. Uses min(width, height) so -// that a portrait video's renditions get the same "360p / 540p / 720p" labels as a landscape -// source — the streaming convention is to label by the short side, not format.height which is -// the long side for portrait content. -fun getCurrentPlayingShortSide(tracks: Tracks): Int? { - val group = getVideoTrackGroup(tracks) ?: return null +// Finds the track with the smallest short side (min(width, height)) in the given video group. +// Returns null if no track has a positive short side. Used to force lowest-resolution playback +// in feeds to save bandwidth. +@OptIn(UnstableApi::class) +fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { + var bestIndex: Int? = null + var bestShortSide = Int.MAX_VALUE for (i in 0 until group.length) { - if (group.isTrackSelected(i)) { - val format = group.getTrackFormat(i) - return minOf(format.width, format.height).takeIf { it > 0 } + val format = group.getTrackFormat(i) + val shortSide = minOf(format.width, format.height) + if (shortSide > 0 && shortSide < bestShortSide) { + bestShortSide = shortSide + bestIndex = i } } - return null + return bestIndex +} + +@OptIn(UnstableApi::class) +fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } + +fun clearVideoOverride(player: Player) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() +} + +fun selectVideoTrack( + player: Player, + group: Tracks.Group, + trackIndex: Int, +) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackIndex)) + .build() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 8a1ad6380..de3e4f3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -51,10 +51,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties -import androidx.media3.common.C import androidx.media3.common.Player -import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes @@ -72,15 +71,26 @@ fun VideoQualityButton( modifier: Modifier = Modifier, ) { var tracks by remember(player) { mutableStateOf(player.currentTracks) } + // Track the rendering video size separately from tracks. In adaptive playback, + // Tracks.Group.isTrackSelected(i) returns true for every rung in the adaptive + // set, so we can't derive the currently-playing resolution from the Tracks + // object. Player.videoSize + onVideoSizeChanged gives the real rendered size + // and updates whenever ABR steps up or down. + var videoSize by remember(player) { mutableStateOf(player.videoSize) } var openDialog by remember { mutableStateOf(false) } DisposableEffect(player) { tracks = player.currentTracks + videoSize = player.videoSize val listener = object : Player.Listener { override fun onTracksChanged(newTracks: Tracks) { tracks = newTracks } + + override fun onVideoSizeChanged(newSize: VideoSize) { + videoSize = newSize + } } player.addListener(listener) onDispose { player.removeListener(listener) } @@ -89,6 +99,8 @@ fun VideoQualityButton( val videoGroup = getVideoTrackGroup(tracks) ?: return if (videoGroup.length <= 1) return + val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, @@ -126,7 +138,7 @@ fun VideoQualityButton( ) { VideoQualityChoices( videoGroup = videoGroup, - currentShortSide = getCurrentPlayingShortSide(tracks), + currentShortSide = currentShortSide, isAuto = !hasVideoOverride(player), onSelectAuto = { clearVideoOverride(player) @@ -210,26 +222,3 @@ private fun formatBitrate(bitrate: Int): String = bitrate >= 1_000_000 -> String.format(Locale.US, "%.1f Mbps", bitrate / 1_000_000.0) else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0) } - -@OptIn(UnstableApi::class) -private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } - -private fun clearVideoOverride(player: Player) { - player.trackSelectionParameters = - player.trackSelectionParameters - .buildUpon() - .clearOverridesOfType(C.TRACK_TYPE_VIDEO) - .build() -} - -private fun selectVideoTrack( - player: Player, - group: Tracks.Group, - trackIndex: Int, -) { - player.trackSelectionParameters = - player.trackSelectionParameters - .buildUpon() - .setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackIndex)) - .build() -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 0252d4835..089705164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -50,6 +51,12 @@ class PipVideoActivity : ComponentActivity() { GetMediaItem(mediaItemData) { mediaItem -> GetVideoController(mediaItem, muted, true) { controllerState -> + // PiP window is small, keep bandwidth low by forcing the lowest + // rendition. User can still manually change quality via controls. + ApplyInitialVideoQuality( + player = controllerState.controller, + isFullscreen = false, + ) RegisterBackgroundMedia(controllerState) RegisterControllerReceiver(controllerState) WatchControllerForActions(mediaItemData, controllerState) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index e4bed1081..19128180c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -570,6 +570,7 @@ private fun RenderImageOrVideo( automaticallyStartPlayback = true, controllerVisible = controllerVisible, hasBlurhash = content.blurhash != null, + isFullscreen = true, accountViewModel = accountViewModel, ) } @@ -630,6 +631,7 @@ private fun RenderImageOrVideo( automaticallyStartPlayback = true, controllerVisible = controllerVisible, hasBlurhash = content.blurhash != null, + isFullscreen = true, accountViewModel = accountViewModel, ) } From cb4a9874980a1b874a0a9ab5393029b7b3bf8d8d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 20 Apr 2026 20:45:38 +0200 Subject: [PATCH 07/34] refactor(video): apply code review fixes --- .../playback/composable/VideoViewInner.kt | 3 +- .../controls/InitialVideoQualitySelector.kt | 75 ++++++++++++------- .../composable/controls/VideoQualityButton.kt | 31 ++++---- ...vailability.kt => VideoQualityControls.kt} | 15 ++-- .../service/playback/pip/PipVideoActivity.kt | 3 +- 5 files changed, 78 insertions(+), 49 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/{VideoQualityAvailability.kt => VideoQualityControls.kt} (77%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index c8b1d009b..e2c164850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality +import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -75,7 +76,7 @@ fun VideoViewInner( ) { controller -> ApplyInitialVideoQuality( player = controller.controller, - isFullscreen = isFullscreen, + policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST, ) VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt index 994d029f5..0b5f200ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -20,77 +20,98 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import androidx.annotation.MainThread import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.media3.common.Player import androidx.media3.common.Tracks +/** + * Which HLS rendition to pick automatically the first time tracks become available for a media + * item. Expressed as an explicit policy instead of a `Boolean` so call sites must commit to one + * value at construction and any future dynamic-state caller is forced to key the effect on it. + */ +enum class VideoQualityPolicy { + /** Lock to the lowest-resolution rendition (feed and PiP: save bandwidth). */ + LOWEST, + + /** Clear any video override so the player uses adaptive bitrate (fullscreen). */ + AUTO, +} + /** * Applies a default video quality when tracks become available on the given player. * - * - In feed context (`isFullscreen = false`): locks to the lowest-resolution rendition to save - * bandwidth. The user can still manually pick any quality (or "Auto") via the quality button. - * - In fullscreen context (`isFullscreen = true`): clears any video override so the player uses - * adaptive bitrate selection (Auto). - * * The initial selection is applied once per media item. If the user later changes the quality * manually, or swaps to a different media item, the new choice wins — we don't reapply for the * same media id. Selections intentionally don't persist across composable lifecycles, so opening - * a feed video in fullscreen starts with Auto and returning to the feed starts with lowest again. + * a feed video in fullscreen starts with [VideoQualityPolicy.AUTO] and returning to the feed + * starts with [VideoQualityPolicy.LOWEST] again. */ @Composable fun ApplyInitialVideoQuality( player: Player, - isFullscreen: Boolean, + policy: VideoQualityPolicy, ) { // Tracks the media id we've already initialized so we don't fight user overrides after the - // first application. Scoped to this composable instance so fullscreen <-> feed transitions - // reset the choice as required (they're separate VideoViewInner instances with separate - // players, so isFullscreen never flips on a given instance). - val appliedForMediaId = remember(player) { arrayOf(null) } + // first application. + val appliedForMediaId = remember(player) { mutableStateOf(null) } + + DisposableEffect(player, policy) { + // Re-arm the guard whenever the player or the policy changes so a new policy gets a + // chance to apply even if the same media id has already been handled under the old one. + appliedForMediaId.value = null - DisposableEffect(player) { val listener = object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { - applyInitialQuality(player, tracks, isFullscreen, appliedForMediaId) + applyInitialQuality(player, tracks, policy, appliedForMediaId) } } // Tracks might already be available by the time we attach the listener. - applyInitialQuality(player, player.currentTracks, isFullscreen, appliedForMediaId) + applyInitialQuality(player, player.currentTracks, policy, appliedForMediaId) player.addListener(listener) onDispose { player.removeListener(listener) } } } +// Invoked from Player.Listener callbacks and DisposableEffect bodies, both of which run on +// the player's application looper (main thread for ExoPlayer). The body mutates Compose state +// and trackSelectionParameters; both are main-thread-only. +@MainThread private fun applyInitialQuality( player: Player, tracks: Tracks, - isFullscreen: Boolean, - appliedForMediaId: Array, + policy: VideoQualityPolicy, + appliedForMediaId: MutableState, ) { val mediaId = player.currentMediaItem?.mediaId ?: return - if (appliedForMediaId[0] == mediaId) return + if (appliedForMediaId.value == mediaId) return val videoGroup = getVideoTrackGroup(tracks) ?: return - // No point forcing a choice when there's only one rendition. + // No point forcing a choice when there's only one rendition, and no future update will + // change that for this media id, so mark it as settled. if (videoGroup.length <= 1) { - appliedForMediaId[0] = mediaId + appliedForMediaId.value = mediaId return } - if (isFullscreen) { - // Ensure adaptive selection is active by removing any pre-existing video override. - if (hasVideoOverride(player)) { - clearVideoOverride(player) + when (policy) { + VideoQualityPolicy.AUTO -> { + if (hasVideoOverride(player)) clearVideoOverride(player) + appliedForMediaId.value = mediaId } - } else { - val lowestIndex = findLowestResolutionTrackIndex(videoGroup) - if (lowestIndex != null) { + + VideoQualityPolicy.LOWEST -> { + // If no supported track has a positive short side yet, leave the guard unset so we + // retry on the next onTracksChanged when real video dimensions arrive. + val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return selectVideoTrack(player, videoGroup, lowestIndex) + appliedForMediaId.value = mediaId } } - appliedForMediaId[0] = mediaId } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index de3e4f3be..6a3fb5539 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -71,26 +71,15 @@ fun VideoQualityButton( modifier: Modifier = Modifier, ) { var tracks by remember(player) { mutableStateOf(player.currentTracks) } - // Track the rendering video size separately from tracks. In adaptive playback, - // Tracks.Group.isTrackSelected(i) returns true for every rung in the adaptive - // set, so we can't derive the currently-playing resolution from the Tracks - // object. Player.videoSize + onVideoSizeChanged gives the real rendered size - // and updates whenever ABR steps up or down. - var videoSize by remember(player) { mutableStateOf(player.videoSize) } var openDialog by remember { mutableStateOf(false) } DisposableEffect(player) { tracks = player.currentTracks - videoSize = player.videoSize val listener = object : Player.Listener { override fun onTracksChanged(newTracks: Tracks) { tracks = newTracks } - - override fun onVideoSizeChanged(newSize: VideoSize) { - videoSize = newSize - } } player.addListener(listener) onDispose { player.removeListener(listener) } @@ -99,8 +88,6 @@ fun VideoQualityButton( val videoGroup = getVideoTrackGroup(tracks) ?: return if (videoGroup.length <= 1) return - val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } - AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, @@ -131,6 +118,21 @@ fun VideoQualityButton( } if (openDialog) { + // Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every + // rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open. + var videoSize by remember(player) { mutableStateOf(player.videoSize) } + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onVideoSizeChanged(newSize: VideoSize) { + videoSize = newSize + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + Popup( alignment = Alignment.BottomCenter, onDismissRequest = { openDialog = false }, @@ -141,7 +143,7 @@ fun VideoQualityButton( currentShortSide = currentShortSide, isAuto = !hasVideoOverride(player), onSelectAuto = { - clearVideoOverride(player) + if (hasVideoOverride(player)) clearVideoOverride(player) openDialog = false }, onSelectTrack = { trackIndex -> @@ -207,6 +209,7 @@ private data class QualityChoice( private fun buildQualityChoices(group: Tracks.Group): ImmutableList { val choices = mutableListOf() for (i in 0 until group.length) { + if (!group.isTrackSupported(i)) continue val format = group.getTrackFormat(i) val shortSide = minOf(format.width, format.height) if (shortSide > 0) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt index 28e2bf2a8..f62ba4353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt @@ -27,16 +27,18 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } +internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } // Finds the track with the smallest short side (min(width, height)) in the given video group. // Returns null if no track has a positive short side. Used to force lowest-resolution playback -// in feeds to save bandwidth. +// in feeds to save bandwidth. Skips tracks the device can't decode — ExoPlayer would silently +// reject an override pointing at an unsupported track and fall back to adaptive selection. @OptIn(UnstableApi::class) -fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { +internal fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { var bestIndex: Int? = null var bestShortSide = Int.MAX_VALUE for (i in 0 until group.length) { + if (!group.isTrackSupported(i)) continue val format = group.getTrackFormat(i) val shortSide = minOf(format.width, format.height) if (shortSide > 0 && shortSide < bestShortSide) { @@ -48,9 +50,9 @@ fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { } @OptIn(UnstableApi::class) -fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } +internal fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } -fun clearVideoOverride(player: Player) { +internal fun clearVideoOverride(player: Player) { player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() @@ -58,7 +60,8 @@ fun clearVideoOverride(player: Player) { .build() } -fun selectVideoTrack( +@OptIn(UnstableApi::class) +internal fun selectVideoTrack( player: Player, group: Tracks.Group, trackIndex: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 089705164..5c21ecaf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -35,6 +35,7 @@ import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality +import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -55,7 +56,7 @@ class PipVideoActivity : ComponentActivity() { // rendition. User can still manually change quality via controls. ApplyInitialVideoQuality( player = controllerState.controller, - isFullscreen = false, + policy = VideoQualityPolicy.LOWEST, ) RegisterBackgroundMedia(controllerState) RegisterControllerReceiver(controllerState) From da3d9cf0d7c6d49b700a3979f5b82fb8c1a9256c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 20 Apr 2026 19:29:53 +0000 Subject: [PATCH 08/34] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 473b78618..1b4e6d170 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -386,6 +386,37 @@ Ankiety Otwarte Zamknięte + Odznaki + Otrzymano + Moje + Przyznane + Odkryj + Nowa odznaka + Edytuj odznakę + Znak wyróżnienia + Zaakceptuj + Odrzuć + Usuń z profilu + ID odznaki (d-tag) + Nazwa + Nazwa odznaki czytelna dla człowieka + Opis + Za co przyznawana jest ta odznaka? + Adres URL obrazu (1024×1024) + https://domena.pl/plakietka.png + URL miniatury (opcjonalnie) + https://domena.pl/miniatura-plakietki.png + Wgraj obraz + Wybierz kwadratowe zdjęcie, które posłuży jako wizerunek Twojej odznaki. + Ładowanie odznaki… + Szukaj użytkowników + Nazwa, npub lub NIP-05 + Usuń + Przyznano dla %1$d + Otrzymałeś odznakę + Odznaki · %1$d + Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu. + Nie otrzymałeś jeszcze żadnych odznak. Zdjęcia Filmiki Filmy wideo @@ -400,6 +431,8 @@ Twoje przypięte notatki Przypnij do profilu Odepnij z profilu + Usuń z listy + Odrzuć Listy zakładek Ikona listy zakładek Nowa lista zakładek @@ -412,6 +445,8 @@ Zobacz linki Zobacz Hashtagi Nie masz jeszcze żadnych list zakładek. Naciśnij nowy przycisk poniżej, aby utworzyć listę. + Zmień nazwę + Sklonuj Prywatne posty Prywatnych postów: (%1$s) Publiczne posty @@ -1486,7 +1521,15 @@ Lokalizacje Społeczności Listy + Algorytmy kanału + Wszystkie ulubione algorytmy kanału Transmitery + Dodaj algorytm kanału do ulubionych + Usuń z ulubionych + Przetwarzanie statusu kanału… + Ten algorytm kanału jest płatny + Algorytm kanału zwrócił błąd + Ponów Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość From de78bceeaefa6971c275df8c93b0ccca7ea30b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:01:51 +0000 Subject: [PATCH 09/34] chore(marmot-interop): diagnostics for invite-delivery failures Adds a dump_daemon_diagnostics helper (relays per type, wn debug health, relay-control-state, pending invites, wnd stderr tail) and wires it into Test 02 and Test 04 on both sides of the poll (pre-invite baseline + post-timeout). wait_for_invite now prints a ~10s heartbeat with the pending-invite count and the most recent welcome/giftwrap line from wnd stderr. On timeout the script also prompts the operator to paste the Amethyst-side MarmotDbg logcat so both ends of the gift-wrap pipe end up in one log file. --- tools/marmot-interop/lib.sh | 134 +++++++++++++++++++++++-- tools/marmot-interop/marmot-interop.sh | 33 +++++- 2 files changed, 155 insertions(+), 12 deletions(-) diff --git a/tools/marmot-interop/lib.sh b/tools/marmot-interop/lib.sh index aaa04bfae..4194d3e24 100644 --- a/tools/marmot-interop/lib.sh +++ b/tools/marmot-interop/lib.sh @@ -175,21 +175,45 @@ jq_group_id() { # wait_for_invite # Echoes the first new group_id that appears in `wn groups invites --json`. # Returns 1 on timeout. +# +# Also prints a heartbeat every ~10s with: +# - elapsed/remaining time +# - current count of pending welcomes (should be 0 until it isn't) +# - last relevant line of wnd stderr (grep for welcome/giftwrap/subscribe/error) +# so a stalled poll gives the operator something to forward. wait_for_invite() { - local who="$1" timeout="${2:-60}" deadline gid - deadline=$(( $(date +%s) + timeout )) + local who="$1" timeout="${2:-60}" deadline gid start last_hb + local wnfn data_dir + if [[ "$who" == "B" ]]; then wnfn=wn_b; data_dir="$B_DIR" + else wnfn=wn_c; data_dir="$C_DIR"; fi + start=$(date +%s) + deadline=$(( start + timeout )) + last_hb=$start while [[ $(date +%s) -lt $deadline ]]; do - if [[ "$who" == "B" ]]; then - gid=$(wn_b_json groups invites 2>/dev/null \ - | jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true) - else - gid=$(wn_c_json groups invites 2>/dev/null \ - | jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true) - fi + gid=$("$wnfn" --json groups invites 2>/dev/null \ + | jq -c '.[0] // empty' 2>/dev/null | jq_group_id || true) if [[ -n "${gid:-}" ]]; then printf '%s\n' "$gid" return 0 fi + + # Heartbeat every ~10s. + local now=$(date +%s) + if (( now - last_hb >= 10 )); then + local elapsed=$(( now - start )) remaining=$(( deadline - now )) + local pending + pending=$("$wnfn" --json groups invites 2>/dev/null \ + | jq 'length' 2>/dev/null || echo "?") + local recent="" + if [[ -f "$data_dir/logs/stderr.log" ]]; then + recent=$(tail -n 200 "$data_dir/logs/stderr.log" 2>/dev/null \ + | grep -iE 'welcome|giftwrap|gift_wrap|mls|subscribe|1059|444|error|warn' \ + | tail -n 1 || true) + fi + info "$who wait_for_invite +${elapsed}s (remaining ${remaining}s) pending=$pending tail: ${recent:-}" + last_hb=$now + fi + sleep 2 done return 1 @@ -297,6 +321,98 @@ load_state() { grep "^${key}=" "$STATE_FILE_RUNTIME" 2>/dev/null | tail -n1 | cut -d= -f2- } +# ------- daemon diagnostics -------------------------------------------------- +# Dumps everything we know about a daemon's state. Called before polling for +# invites (baseline) and on every invite-poll failure so the operator can +# forward a single log file that explains what the daemon saw (or didn't). +# +# Output is tee'd to both stderr (for live viewing) and $LOG_FILE (for forward). +dump_daemon_diagnostics() { + local who="$1" tag="${2:-diagnostics}" data_dir socket wnfn npub hex + if [[ "$who" == "B" ]]; then + data_dir="$B_DIR"; socket="$B_SOCKET"; wnfn=wn_b + npub="${B_NPUB:-?}"; hex="${B_HEX:-?}" + else + data_dir="$C_DIR"; socket="$C_SOCKET"; wnfn=wn_c + npub="${C_NPUB:-?}"; hex="${C_HEX:-?}" + fi + + printf '\n%s%s---- [%s] %s daemon diagnostics ----%s\n' \ + "$C_BOLD" "$C_CYAN" "$tag" "$who" "$C_RESET" >&2 + printf '\n==== [%s] %s daemon diagnostics ====\n' "$tag" "$who" >>"$LOG_FILE" + + info "$who identity npub=$npub" + info "$who identity hex =$hex" + info "$who socket $socket" + + # Relay list grouped by type — this is the critical bit for invite + # debugging. whitenoise-rs subscribes to kind:1059 on Inbox relays only, + # so if Amethyst doesn't see $B_NPUB's kind:10050 list pointing at these + # URLs, the gift wrap will never reach B. + step "$who relays (per type):" + for t in nip65 inbox key_package; do + local raw urls + raw=$("$wnfn" --json relays list --type "$t" 2>/dev/null || true) + urls=$(printf '%s' "$raw" \ + | jq -r '(.result // .) | .[]? | (.url // .relay.url // empty)' 2>/dev/null \ + | paste -sd',' -) + info " $t: ${urls:-}" + printf ' %s %s raw: %s\n' "$who" "$t" "$raw" >>"$LOG_FILE" + done + + # Connection status per relay — shows which sockets are actually live. + step "$who relay connection status:" + "$wnfn" --json relays list 2>/dev/null \ + | jq -r '(.result // .)[]? | " \(.url // .relay.url // "?") [\(.type // "?")] [\(.status // .connection_status // "?")]"' 2>/dev/null \ + | tee -a "$LOG_FILE" >&2 || true + + # Daemon-level subscription health. + step "$who wn debug health:" + local health + health=$("$wnfn" --json debug health 2>/dev/null || "$wnfn" debug health 2>/dev/null || true) + printf ' %s\n' "${health:-}" | tee -a "$LOG_FILE" >&2 + + # Relay-control state (subscriptions, filters, planes). Truncated to keep + # stderr readable; full copy still goes to $LOG_FILE. + step "$who wn debug relay-control-state (truncated to stderr; full in $LOG_FILE):" + local rcs + rcs=$("$wnfn" --json debug relay-control-state 2>/dev/null || "$wnfn" debug relay-control-state 2>/dev/null || true) + printf '%s\n' "${rcs:-}" | head -n 20 | sed 's/^/ /' >&2 + printf '%s wn debug relay-control-state FULL:\n%s\n' "$who" "${rcs:-}" >>"$LOG_FILE" + + # Pending welcomes already decrypted by the daemon. Non-empty here while + # wait_for_invite is polling would be a race worth noticing. + step "$who pending invites:" + local inv + inv=$("$wnfn" --json groups invites 2>/dev/null || true) + printf ' %s\n' "${inv:-}" | tee -a "$LOG_FILE" >&2 + + # Tail of daemon stderr — contains the live 1059/welcome processing logs. + step "$who wnd stderr (last 80 lines from $data_dir/logs/stderr.log):" + if [[ -f "$data_dir/logs/stderr.log" ]]; then + tail -n 80 "$data_dir/logs/stderr.log" 2>/dev/null | sed 's/^/ /' | tee -a "$LOG_FILE" >&2 || true + else + info " (no stderr log at $data_dir/logs/stderr.log)" + fi + + printf '%s%s---- end %s diagnostics ----%s\n\n' \ + "$C_BOLD" "$C_CYAN" "$who" "$C_RESET" >&2 + printf '==== end [%s] %s diagnostics ====\n\n' "$tag" "$who" >>"$LOG_FILE" +} + +# Asks the operator to capture Amethyst's MarmotDbg logcat so the script log +# ends up with both sides of the pipe in one place. +prompt_amethyst_logcat() { + local note="${1:-paste below}" + prompt_human "On the host running adb, capture Amethyst's Marmot logs: + + adb logcat -d -v time | grep -E 'MarmotDbg|Marmot |MlsWelcome|GiftWrap' \\ + | tail -n 200 > /tmp/amethyst-marmot.log + +Then paste /tmp/amethyst-marmot.log contents here so the failure report has +both the Amethyst-side publish log and the wn-side subscription log ($note)." +} + # ------- group cleanup ------------------------------------------------------- cleanup_group() { local gid="$1" who="${2:-B}" diff --git a/tools/marmot-interop/marmot-interop.sh b/tools/marmot-interop/marmot-interop.sh index a7c60e96f..132ec3012 100755 --- a/tools/marmot-interop/marmot-interop.sh +++ b/tools/marmot-interop/marmot-interop.sh @@ -318,16 +318,30 @@ test_01_keypackage_discovery() { test_02_amethyst_creates_group() { banner "Test 02 — Amethyst creates group, invites B" + # Baseline dump BEFORE the human triggers the publish in Amethyst. + # If this diagnostic shows B has no inbox relays (or the inbox relays + # differ from the ones Amethyst has cached for B's kind:10050 list), + # the welcome gift wrap will never reach B no matter how correctly + # Amethyst sends it. + dump_daemon_diagnostics B "pre-invite baseline" + prompt_human "In Amethyst: 1. Tap '+' -> Create Group 2. Name: Interop-02 3. Add member: $B_NPUB - 4. Tap Create / Send Invite" + 4. Tap Create / Send Invite - step "polling B's daemon for invite (60s)" +Tip: watch the Android logcat for tag 'MarmotDbg' — you should see +'publishing welcome gift wrap id=… kind:1059 → N relay(s): [...]' listing +the same relays as B's inbox above. If the list is empty or different, +that's the bug." + + step "polling B's daemon for invite (60s, heartbeat every ~10s)" local gid if ! gid=$(wait_for_invite B 60); then fail_msg "no invite arrived at B" + dump_daemon_diagnostics B "post-timeout (test_02)" + prompt_amethyst_logcat "test_02 timeout" record_result "02 Amethyst->B create+invite" fail "invite never arrived" return fi @@ -431,14 +445,18 @@ test_04_three_member_group() { fi info "using group $gid" + dump_daemon_diagnostics C "pre-invite baseline (test_04)" + prompt_human "In Amethyst, open the group from Test 02 (or 'Interop-04-bootstrap'). Group Info -> Add Member -> paste: $C_NPUB Confirm the invite is sent." - step "polling C for invite (60s)" + step "polling C for invite (60s, heartbeat every ~10s)" local c_gid if ! c_gid=$(wait_for_invite C 60); then fail_msg "C never received invite" + dump_daemon_diagnostics C "post-timeout (test_04)" + prompt_amethyst_logcat "test_04 timeout" record_result "04 3-member add-after-create" fail "invite to C missing" return fi @@ -865,6 +883,15 @@ main() { ensure_identity C prompt_for_a_npub configure_relays + + # Baseline dump after relays are configured but before any tests run. + # This is the single most useful log to forward when Test 02 fails — + # it shows whether B's kind:10050 (inbox) relay list actually landed + # on the relays Amethyst will look at for the giftwrap delivery target. + banner "Post-configure baseline diagnostics" + dump_daemon_diagnostics B "post-configure" + dump_daemon_diagnostics C "post-configure" + instruct_amethyst_setup test_01_keypackage_discovery From 00ed676c6bbb4068e822867a6cb63f9df61f716f Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 20 Apr 2026 20:08:38 +0000 Subject: [PATCH 10/34] New Crowdin translations by GitHub Action --- .../src/main/res/values-pl-rPL/strings.xml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 473b78618..1b4e6d170 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -386,6 +386,37 @@ Ankiety Otwarte Zamknięte + Odznaki + Otrzymano + Moje + Przyznane + Odkryj + Nowa odznaka + Edytuj odznakę + Znak wyróżnienia + Zaakceptuj + Odrzuć + Usuń z profilu + ID odznaki (d-tag) + Nazwa + Nazwa odznaki czytelna dla człowieka + Opis + Za co przyznawana jest ta odznaka? + Adres URL obrazu (1024×1024) + https://domena.pl/plakietka.png + URL miniatury (opcjonalnie) + https://domena.pl/miniatura-plakietki.png + Wgraj obraz + Wybierz kwadratowe zdjęcie, które posłuży jako wizerunek Twojej odznaki. + Ładowanie odznaki… + Szukaj użytkowników + Nazwa, npub lub NIP-05 + Usuń + Przyznano dla %1$d + Otrzymałeś odznakę + Odznaki · %1$d + Wybierz, które z otrzymanych odznak pojawią się na Twoim profilu. + Nie otrzymałeś jeszcze żadnych odznak. Zdjęcia Filmiki Filmy wideo @@ -400,6 +431,8 @@ Twoje przypięte notatki Przypnij do profilu Odepnij z profilu + Usuń z listy + Odrzuć Listy zakładek Ikona listy zakładek Nowa lista zakładek @@ -412,6 +445,8 @@ Zobacz linki Zobacz Hashtagi Nie masz jeszcze żadnych list zakładek. Naciśnij nowy przycisk poniżej, aby utworzyć listę. + Zmień nazwę + Sklonuj Prywatne posty Prywatnych postów: (%1$s) Publiczne posty @@ -1486,7 +1521,15 @@ Lokalizacje Społeczności Listy + Algorytmy kanału + Wszystkie ulubione algorytmy kanału Transmitery + Dodaj algorytm kanału do ulubionych + Usuń z ulubionych + Przetwarzanie statusu kanału… + Ten algorytm kanału jest płatny + Algorytm kanału zwrócił błąd + Ponów Wyloguj się przy blokowaniu urządzenia Wiadomość prywatna Publiczna wiadomość From 0f8498187966016726276bec8ebc861a33e2dd8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:28:09 +0000 Subject: [PATCH 11/34] feat(communities): redesign New Community screen - Large tap-to-pick cover image at the top (Badge-style): shows an aspect-16:9 placeholder that opens the gallery, uploads to the user's chosen media server, and previews via ShowImageUploadGallery. - Moderators section: lists the current user as pinned "Owner" plus each added moderator (picture + display name + NIP-05) with a remove icon. The add field is powered by ShowUserSuggestionList so names, npubs and NIP-05s all search live. - Relays section: each row uses BasicRelaySetupInfoClickableRow so users see the full relay stats (icon, users, event counts, status) just like the AllRelayList screen. Below each row a FilterChip group sets the NIP-72 marker (any / author / requests / approvals). A RelayUrlEditField at the bottom adds new relays with live search suggestions. - Reworked NewCommunityModel: dedicated mutableStateListOf for moderators and relays, proper image upload via MultiOrchestrator, CommunityRelayEntry data class, publish() now uploads then signs kind 34550 and follows it (so the new community appears in "Mine"). - Updated strings and localized resources. --- .../newCommunity/NewCommunityModel.kt | 177 ++++-- .../newCommunity/NewCommunityScreen.kt | 563 +++++++++++++++--- amethyst/src/main/res/values/strings.xml | 18 +- 3 files changed, 618 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index bf6f749bd..2e480fb5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -21,24 +21,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity import android.content.Context +import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +@Immutable +data class CommunityRelayEntry( + val url: NormalizedRelayUrl, + val marker: String? = null, +) + @Stable class NewCommunityModel : ViewModel() { var account: Account? = null @@ -47,15 +65,65 @@ class NewCommunityModel : ViewModel() { var name by mutableStateOf("") var description by mutableStateOf("") - var imageUrl by mutableStateOf("") var rules by mutableStateOf("") - var moderatorsText by mutableStateOf("") - var relayRequestsUrl by mutableStateOf("") - var relayApprovalsUrl by mutableStateOf("") + + // Image upload state - mirrors NewBadgeModel. + var multiOrchestrator by mutableStateOf(null) + var selectedServer by mutableStateOf(null) + + // 0 = Low, 1 = Medium, 2 = High, 3 = UNCOMPRESSED + var mediaQualitySlider by mutableIntStateOf(1) + var stripMetadata by mutableStateOf(true) + + val strippingFailureConfirmation = SuspendableConfirmation() + + // Moderator/Relay lists - backed by mutableStateListOf for direct Compose observation. + val moderators = mutableStateListOf() + val relays = mutableStateListOf() fun init(account: Account) { if (this.account == account) return this.account = account + this.selectedServer = defaultServer() + this.stripMetadata = account.settings.stripLocationOnUpload + } + + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] + + fun setPickedMedia(uris: ImmutableList) { + this.multiOrchestrator = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun hasPickedImage(): Boolean = multiOrchestrator != null + + fun addModerator(user: User) { + if (moderators.none { it.pubkeyHex == user.pubkeyHex }) { + moderators.add(user) + } + } + + fun removeModerator(user: User) { + moderators.removeAll { it.pubkeyHex == user.pubkeyHex } + } + + fun addRelay(url: NormalizedRelayUrl) { + if (relays.none { it.url == url }) { + relays.add(CommunityRelayEntry(url)) + } + } + + fun removeRelay(entry: CommunityRelayEntry) { + relays.removeAll { it.url == entry.url } + } + + fun setRelayMarker( + entry: CommunityRelayEntry, + marker: String?, + ) { + val index = relays.indexOfFirst { it.url == entry.url } + if (index >= 0) { + relays[index] = entry.copy(marker = marker) + } } fun canPost(): Boolean = @@ -88,15 +156,33 @@ class NewCommunityModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { isPublishing = true try { - val moderatorTags = parseModeratorTags(myAccount) - val relayTags = parseRelayTags() + val imageUrl = + uploadImageIfAny(context, myAccount, onError) ?: run { + if (multiOrchestrator != null) { + // upload failed; error was reported, bail out + return@launch + } + null + } + + val ownerKey = myAccount.signer.pubKey + val moderatorTags = + buildList { + add(ModeratorTag(ownerKey, null, "moderator")) + moderators + .asSequence() + .filter { it.pubkeyHex != ownerKey } + .forEach { add(ModeratorTag(it.pubkeyHex, null, "moderator")) } + } + + val relayTags = relays.map { RelayTag(it.url, it.marker) } val definition = myAccount.sendCommunityDefinition( name = name.trim(), description = description.trim(), moderators = moderatorTags, - image = imageUrl.trim().ifBlank { null }, + image = imageUrl, rules = rules.trim().ifBlank { null }, relays = relayTags.ifEmpty { null }, dTag = Uuid.random().toString(), @@ -110,10 +196,13 @@ class NewCommunityModel : ViewModel() { return@launch } - // Also follow the community so it appears on the user's list. + // Follow it so it shows up under "Mine". val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) myAccount.follow(communityNote) + selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) } + myAccount.settings.changeStripLocationOnUpload(stripMetadata) + reset() onSuccess() } finally { @@ -122,42 +211,58 @@ class NewCommunityModel : ViewModel() { } } - private fun parseModeratorTags(account: Account): List { - val hexes = - moderatorsText - .lineSequence() - .map { it.trim() } - .filter { it.length == 64 && it.all { ch -> ch.isDigit() || (ch in 'a'..'f') || (ch in 'A'..'F') } } - .map { it.lowercase() } - .toSet() + private suspend fun uploadImageIfAny( + context: Context, + myAccount: Account, + onError: (String, String) -> Unit, + ): String? { + val orch = multiOrchestrator ?: return null + val serverToUse = selectedServer ?: defaultServer() - val withOwner = hexes + account.signer.pubKey + val results = + orch.upload( + alt = name.trim().ifBlank { "Community cover" }, + contentWarningReason = null, + mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider), + server = serverToUse, + account = myAccount, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) - return withOwner.map { ModeratorTag(it, null, "moderator") } - } - - private fun parseRelayTags(): List { - val tags = mutableListOf() - relayRequestsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_REQUESTS) - } + if (!results.allGood) { + val messages = + results.errors + .map { stringRes(context, it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + onError(stringRes(context, R.string.failed_to_upload_media_no_details), messages) + return null } - relayApprovalsUrl.trim().takeIf { it.isNotEmpty() }?.let { - RelayUrlNormalizer.normalizeOrNull(it)?.let { url -> - tags += RelayTag(url, RelayTag.MARKER_APPROVALS) + + val uploaded = + results.successful.firstNotNullOfOrNull { + it.result as? UploadOrchestrator.OrchestratorResult.ServerResult + } ?: run { + onError( + stringRes(context, R.string.failed_to_upload_media_no_details), + "Upload succeeded but no image URL was returned by the server.", + ) + return null } - } - return tags + + return uploaded.url } fun reset() { name = "" description = "" - imageUrl = "" rules = "" - moderatorsText = "" - relayRequestsUrl = "" - relayApprovalsUrl = "" + multiOrchestrator = null + moderators.clear() + relays.clear() + selectedServer = defaultServer() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index fe4055a4a..c68d871d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -20,35 +20,77 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect +import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag +import kotlinx.collections.immutable.persistentListOf @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -57,117 +99,440 @@ fun NewCommunityScreen( nav: INav, ) { val model: NewCommunityModel = viewModel() + val context = LocalContext.current LaunchedEffect(accountViewModel.account) { model.init(accountViewModel.account) } - val context = LocalContext.current - var errorTitle by remember { mutableStateOf(null) } - var errorBody by remember { mutableStateOf(null) } + StrippingFailureDialog(model.strippingFailureConfirmation) + + var wantsToPickImage by remember { mutableStateOf(false) } + + if (wantsToPickImage) { + GallerySelect( + onImageUri = { uris -> + wantsToPickImage = false + model.setPickedMedia( + if (uris.isNotEmpty()) persistentListOf(uris.first()) else persistentListOf(), + ) + }, + ) + } Scaffold( topBar = { - TopBarWithBackButton( - caption = stringRes(R.string.new_community), - popBack = { nav.popBack() }, - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 16.dp, vertical = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedTextField( - value = model.name, - onValueChange = { model.name = it }, - label = { Text(stringRes(R.string.new_community_name)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.description, - onValueChange = { model.description = it }, - label = { Text(stringRes(R.string.new_community_description)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.imageUrl, - onValueChange = { model.imageUrl = it }, - label = { Text(stringRes(R.string.new_community_image_url)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.rules, - onValueChange = { model.rules = it }, - label = { Text(stringRes(R.string.new_community_rules)) }, - minLines = 3, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.moderatorsText, - onValueChange = { model.moderatorsText = it }, - label = { Text(stringRes(R.string.new_community_moderators)) }, - minLines = 2, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayRequestsUrl, - onValueChange = { model.relayRequestsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_requests)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = model.relayApprovalsUrl, - onValueChange = { model.relayApprovalsUrl = it }, - label = { Text(stringRes(R.string.new_community_relay_approvals)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - errorTitle?.let { title -> - Text( - text = title, - fontWeight = FontWeight.Bold, - ) - errorBody?.let { body -> - Text(text = body) - } - } - - Button( - onClick = { - errorTitle = null - errorBody = null + CreatingTopBar( + titleRes = R.string.new_community, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { model.publish( context = context, onSuccess = { nav.popBack() }, - onError = { title, body -> - errorTitle = title - errorBody = body - }, + onError = accountViewModel.toastManager::toast, ) }, - enabled = model.canPost(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), - modifier = Modifier.fillMaxWidth(), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(text = stringRes(R.string.new_community_publish)) + CommunityImagePicker( + model = model, + accountViewModel = accountViewModel, + onPickImage = { wantsToPickImage = true }, + ) + + OutlinedTextField( + value = model.name, + onValueChange = { model.name = it }, + label = { Text(stringRes(R.string.new_community_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.description, + onValueChange = { model.description = it }, + label = { Text(stringRes(R.string.new_community_description)) }, + minLines = 3, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + OutlinedTextField( + value = model.rules, + onValueChange = { model.rules = it }, + label = { Text(stringRes(R.string.new_community_rules)) }, + minLines = 2, + maxLines = 8, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_moderators_section) + ModeratorsSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) + + HorizontalDivider() + + SectionHeader(R.string.new_community_relays_section) + RelaysSection( + model = model, + accountViewModel = accountViewModel, + nav = nav, + ) } } } } + +@Composable +private fun SectionHeader(resourceId: Int) { + Text( + text = stringRes(resourceId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) +} + +@Composable +private fun CommunityImagePicker( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + onPickImage: () -> Unit, +) { + if (model.hasPickedImage()) { + model.multiOrchestrator?.let { + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { model.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } + } + } else { + CommunityImagePlaceholder(onClick = onPickImage) + } +} + +@Composable +private fun CommunityImagePlaceholder(onClick: () -> Unit) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(16.dp), + ).clickable(onClick = onClick) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.new_community_pick_cover_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +// --- Moderators -------------------------------------------------------------------------------- + +@Composable +private fun ModeratorsSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + var search by remember { mutableStateOf("") } + + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + + DisposableEffect(Unit) { + onDispose { userSuggestions.reset() } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_moderators_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Current user is always a moderator (creator) + SelectedModeratorRow( + user = accountViewModel.account.userProfile(), + accountViewModel = accountViewModel, + nav = nav, + isOwner = true, + onRemove = null, + ) + + model.moderators.toList().forEach { user -> + SelectedModeratorRow( + user = user, + accountViewModel = accountViewModel, + nav = nav, + isOwner = false, + onRemove = { model.removeModerator(user) }, + ) + } + + OutlinedTextField( + value = search, + onValueChange = { + search = it + if (it.length > 1) { + userSuggestions.processCurrentWord(it) + } else { + userSuggestions.reset() + } + }, + label = { Text(stringRes(R.string.new_community_add_moderator)) }, + placeholder = { Text(stringRes(R.string.new_community_add_moderator_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + if (search.length > 1) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (user.pubkeyHex != accountViewModel.account.userProfile().pubkeyHex) { + model.addModerator(user) + } + search = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + } +} + +@Composable +private fun SelectedModeratorRow( + user: User, + accountViewModel: AccountViewModel, + nav: INav, + isOwner: Boolean, + onRemove: (() -> Unit)?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture( + userHex = user.pubkeyHex, + size = 40.dp, + accountViewModel = accountViewModel, + nav = nav, + ) + Column( + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ModeratorSecondaryLine(user) + } + + if (isOwner) { + AssistChip( + onClick = {}, + label = { Text(stringRes(R.string.new_community_owner)) }, + enabled = false, + colors = + AssistChipDefaults.assistChipColors( + disabledContainerColor = MaterialTheme.colorScheme.primaryContainer, + disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(R.string.remove), + ) + } + } + } +} + +@Composable +private fun ModeratorSecondaryLine(user: User) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + val text = + when (val state = nip05StateMetadata) { + is Nip05State.Exists -> { + val name = state.nip05.name + if (name == "_") state.nip05.domain else "$name@${state.nip05.domain}" + } + + else -> { + user.pubkeyDisplayHex() + } + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +// --- Relays ------------------------------------------------------------------------------------ + +@Composable +private fun RelaysSection( + model: NewCommunityModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringRes(R.string.new_community_relays_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + model.relays.toList().forEach { entry -> + val info = remember(entry.url) { relaySetupInfoBuilder(entry.url) } + + Column { + BasicRelaySetupInfoClickableRow( + item = info, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + onClick = {}, + onDelete = { model.removeRelay(entry) }, + nip11CachedRetriever = Amethyst.instance.nip11Cache, + accountViewModel = accountViewModel, + nav = nav, + ) + + RelayMarkerChips( + current = entry.marker, + onSelect = { model.setRelayMarker(entry, it) }, + ) + } + } + + RelayUrlEditField( + onNewRelay = { model.addRelay(it) }, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun RelayMarkerChips( + current: String?, + onSelect: (String?) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 56.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_none), + selected = current == null, + onClick = { onSelect(null) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_author), + selected = current == RelayTag.MARKER_AUTHOR, + onClick = { onSelect(RelayTag.MARKER_AUTHOR) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_requests), + selected = current == RelayTag.MARKER_REQUESTS, + onClick = { onSelect(RelayTag.MARKER_REQUESTS) }, + ) + RelayMarkerOption( + label = stringRes(R.string.new_community_relay_marker_approvals), + selected = current == RelayTag.MARKER_APPROVALS, + onClick = { onSelect(RelayTag.MARKER_APPROVALS) }, + ) + } +} + +@Composable +private fun RelayMarkerOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6cc43d0fc..7474e1f40 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -424,12 +424,20 @@ New Community Community name Description - Cover image URL (optional) Rules (optional) - Moderators (one pubkey per line, optional) - Relay for requests (optional) - Relay for approvals (optional) - Publish + Add a cover image + Tap to pick a photo — it will be uploaded to your media server. + Moderators + Moderators can approve posts. You are always a moderator. + Add a moderator + Name, npub, or NIP-05 + Owner + Relays + Pick relays that will host requests, approvals, or the community author\'s metadata. + Any + Author + Requests + Approvals No communities match this filter. Received Mine From 71a29aae57a39f47a91d0b93f925381f1d4d1d8f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Apr 2026 16:27:06 -0400 Subject: [PATCH 12/34] Fixes the reversal of the issue when the FAB becomes unclickable --- .../vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 29253e02b..ec38d6635 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -210,7 +210,7 @@ private fun FloatingButtonHolder( Box( modifier = Modifier.graphicsLayer { - val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0f) + val visible = (1f - state.bottomCollapsedFraction).coerceAtLeast(0.001f) scaleX = visible scaleY = visible alpha = visible From 9e9fd7394e47f7b21529f6aba3305fdf87352a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:38:45 +0000 Subject: [PATCH 13/34] feat(settings): add autoplay videos toggle and clarify video loading description Introduces a dedicated "Autoplay Videos" (Always/Never) preference that controls whether videos start playing automatically when visible in the feed. The pre-existing "Video Playback" preference actually gates auto-downloading, so its description is corrected to say "Automatically load videos and GIFs". https://claude.ai/code/session_01T4MHiQHK6moNu8e6Rx3Nao --- .../amethyst/model/UiSettings.kt | 1 + .../amethyst/model/UiSettingsFlow.kt | 23 +++++++++++++------ .../model/preferences/UISharedPreferences.kt | 3 +++ .../service/playback/composable/VideoView.kt | 16 ++++++------- .../amethyst/ui/screen/UiSettingsState.kt | 2 ++ .../loggedIn/settings/AppSettingsScreen.kt | 21 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 +++- 7 files changed, 54 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 002b2935f..b1e9c9466 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -31,6 +31,7 @@ data class UiSettings( val preferredLanguage: String? = null, val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS, + val automaticallyPlayVideos: BooleanType = BooleanType.ALWAYS, val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS, val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS, val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 37cb82cc3..7a4bbeb1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -31,6 +31,7 @@ class UiSettingsFlow( val preferredLanguage: MutableStateFlow = MutableStateFlow(null), val automaticallyShowImages: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), val automaticallyStartPlayback: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), + val automaticallyPlayVideos: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyShowUrlPreview: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), val automaticallyHideNavigationBars: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), val automaticallyShowProfilePictures: MutableStateFlow = MutableStateFlow(ConnectivityType.ALWAYS), @@ -46,6 +47,7 @@ class UiSettingsFlow( preferredLanguage, automaticallyShowImages, automaticallyStartPlayback, + automaticallyPlayVideos, automaticallyShowUrlPreview, automaticallyHideNavigationBars, automaticallyShowProfilePictures, @@ -64,14 +66,15 @@ class UiSettingsFlow( flows[1] as String?, flows[2] as ConnectivityType, flows[3] as ConnectivityType, - flows[4] as ConnectivityType, - flows[5] as BooleanType, - flows[6] as ConnectivityType, - flows[7] as Boolean, + flows[4] as BooleanType, + flows[5] as ConnectivityType, + flows[6] as BooleanType, + flows[7] as ConnectivityType, flows[8] as Boolean, - flows[9] as FeatureSetType, - flows[10] as ProfileGalleryType, - flows[11] as BooleanType, + flows[9] as Boolean, + flows[10] as FeatureSetType, + flows[11] as ProfileGalleryType, + flows[12] as BooleanType, ) } @@ -81,6 +84,7 @@ class UiSettingsFlow( preferredLanguage.value, automaticallyShowImages.value, automaticallyStartPlayback.value, + automaticallyPlayVideos.value, automaticallyShowUrlPreview.value, automaticallyHideNavigationBars.value, automaticallyShowProfilePictures.value, @@ -110,6 +114,10 @@ class UiSettingsFlow( automaticallyStartPlayback.tryEmit(torSettings.automaticallyStartPlayback) any = true } + if (automaticallyPlayVideos.value != torSettings.automaticallyPlayVideos) { + automaticallyPlayVideos.tryEmit(torSettings.automaticallyPlayVideos) + any = true + } if (automaticallyShowUrlPreview.value != torSettings.automaticallyShowUrlPreview) { automaticallyShowUrlPreview.tryEmit(torSettings.automaticallyShowUrlPreview) any = true @@ -165,6 +173,7 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.preferredLanguage), MutableStateFlow(uiSettings.automaticallyShowImages), MutableStateFlow(uiSettings.automaticallyStartPlayback), + MutableStateFlow(uiSettings.automaticallyPlayVideos), MutableStateFlow(uiSettings.automaticallyShowUrlPreview), MutableStateFlow(uiSettings.automaticallyHideNavigationBars), MutableStateFlow(uiSettings.automaticallyShowProfilePictures), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index c2f573f41..c899fcbdc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -95,6 +95,7 @@ class UiSharedPreferences( val UI_LANGUAGE = stringPreferencesKey("ui.language") val UI_SHOW_IMAGES = stringPreferencesKey("ui.show_images") val UI_START_PLAYBACK = stringPreferencesKey("ui.start_playback") + val UI_PLAY_VIDEOS = stringPreferencesKey("ui.play_videos") val UI_SHOW_URL_PREVIEW = stringPreferencesKey("ui.show_url_preview") val UI_HIDE_NAVIGATION_BARS = stringPreferencesKey("ui.hide_navigation_bars") val UI_SHOW_PROFILE_PICTURES = stringPreferencesKey("ui.show_profile_pictures") @@ -114,6 +115,7 @@ class UiSharedPreferences( preferredLanguage = preferences[UI_LANGUAGE]?.ifBlank { null }, automaticallyShowImages = preferences[UI_SHOW_IMAGES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, automaticallyStartPlayback = preferences[UI_START_PLAYBACK]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, + automaticallyPlayVideos = preferences[UI_PLAY_VIDEOS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, automaticallyShowUrlPreview = preferences[UI_SHOW_URL_PREVIEW]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, automaticallyHideNavigationBars = preferences[UI_HIDE_NAVIGATION_BARS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, automaticallyShowProfilePictures = preferences[UI_SHOW_PROFILE_PICTURES]?.let { ConnectivityType.valueOf(it) } ?: ConnectivityType.ALWAYS, @@ -150,6 +152,7 @@ class UiSharedPreferences( preferences[UI_LANGUAGE] = sharedSettings.preferredLanguage ?: "" preferences[UI_SHOW_IMAGES] = sharedSettings.automaticallyShowImages.name preferences[UI_START_PLAYBACK] = sharedSettings.automaticallyStartPlayback.name + preferences[UI_PLAY_VIDEOS] = sharedSettings.automaticallyPlayVideos.name preferences[UI_SHOW_URL_PREVIEW] = sharedSettings.automaticallyShowUrlPreview.name preferences[UI_HIDE_NAVIGATION_BARS] = sharedSettings.automaticallyHideNavigationBars.name preferences[UI_SHOW_PROFILE_PICTURES] = sharedSettings.automaticallyShowProfilePictures.name diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 6d8cf5382..0a219cd6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -102,12 +102,12 @@ fun VideoView( accountViewModel: AccountViewModel, thumbhash: String? = null, ) { - val automaticallyStartPlayback = - remember { - mutableStateOf( - if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback(), - ) - } + val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback() + val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) } + + // Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded. + // If the user manually tapped the download button, they want it to play. + val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value) if (blurhash == null && thumbhash == null) { val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) @@ -137,7 +137,7 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback.value, + automaticallyStartPlayback = autoplay, onZoom = onDialog, hasBlurhash = false, accountViewModel = accountViewModel, @@ -185,7 +185,7 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback.value, + automaticallyStartPlayback = autoplay, onZoom = onDialog, hasBlurhash = true, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index 70b9bb0d8..d6177d0c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -138,5 +138,7 @@ class UiSettingsState( fun startVideoPlayback() = startVideoPlayback.value + fun autoPlayVideos() = uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS + fun showImages() = showImages.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index ace4f154d..5e7c8caca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -116,6 +116,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ShowThemeChoice(sharedPrefs) ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) + AutoplayVideosChoice(sharedPrefs) ShowUrlPreviewChoice(sharedPrefs) ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) @@ -264,6 +265,26 @@ fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun AutoplayVideosChoice(sharedPrefs: UiSettingsFlow) { + val autoplayIndex by sharedPrefs.automaticallyPlayVideos.collectAsState() + + val booleanItems = + persistentListOf( + TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)), + ) + + SettingsRow( + R.string.autoplay_videos, + R.string.autoplay_videos_description, + booleanItems, + autoplayIndex.screenCode, + ) { + sharedPrefs.automaticallyPlayVideos.tryEmit(parseBooleanType(it)) + } +} + @Composable fun ShowUrlPreviewChoice(sharedPrefs: UiSettingsFlow) { val connectivityBasedOptions = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b537cbcf6..7362efe51 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1093,6 +1093,7 @@ Theme Image Preview Video Playback + Autoplay Videos URL Preview Immersive Scrolling Hide Nav Bars when Scrolling @@ -1184,7 +1185,8 @@ For the App\'s Interface Dark, Light or System theme Automatically load images and GIFs - Automatically plays videos and GIFs + Automatically load videos and GIFs + Automatically play videos when visible on screen Show URL previews When to load images From f9214daba2a4750226b4a28954cf1e65b4883784 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:45:32 +0000 Subject: [PATCH 14/34] fix(marmot): frame outbound commits as MlsMessage(PublicMessage(...)) addMember/removeMember/updateGroupMetadata were publishing the raw TLS Commit struct inside the kind:445 outer ChaCha20 layer, so receivers that started with MlsMessage.decodeTls read the first two bytes of the Commit as the MLS protocol version and aborted with "Unsupported MLS version: " (e.g. 16704 = 0x4140). Wrap commits produced by MlsGroup.commit in a PublicMessage carrying the sender leaf index and confirmation_tag, then in an MlsMessage envelope, and expose those bytes via CommitResult.framedCommitBytes so MarmotManager uses them instead of the raw commit bytes. The internal CommitResult.commitBytes field stays raw so MlsGroup.processCommit and existing tests that exercise it directly keep working. Also mark the committer's own published kind:445 as already processed in MarmotInboundProcessor so that the relay echo of our own commit is treated as a duplicate rather than re-applied on top of the already- advanced local epoch. https://claude.ai/code/session_01NR6JgYRimVb142T2nkChuh --- .../amethyst/commons/marmot/MarmotManager.kt | 13 ++- .../quartz/marmot/MarmotInboundProcessor.kt | 25 ++++++ .../quartz/marmot/mls/group/MlsGroup.kt | 55 ++++++++++++- .../quartz/marmot/mls/messages/Commit.kt | 11 +++ .../quartz/marmot/MarmotPipelineTest.kt | 81 +++++++++++++++++++ 5 files changed, 180 insertions(+), 5 deletions(-) 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 00673dcef..b5b8c94de 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 @@ -179,7 +179,10 @@ class MarmotManager( } val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes) - val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes) + val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) + // We've already applied this commit locally; prevent the relay-echoed + // copy from being re-applied by the inbound pipeline. + inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) val welcomeDelivery = welcomeSender.wrapWelcome( @@ -266,7 +269,9 @@ class MarmotManager( targetLeafIndex: Int, ): OutboundGroupEvent { val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex) - return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes) + val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) + inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) + return commitEvent } /** @@ -278,7 +283,9 @@ class MarmotManager( metadata: MarmotGroupData, ): OutboundGroupEvent { val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) - return outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.commitBytes) + val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) + inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) + return commitEvent } // --- KeyPackage Management --- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 92725aa91..933e8ba0c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -287,6 +287,31 @@ class MarmotInboundProcessor( WelcomeResult.Error("Failed to process Welcome: ${e.message}", e) } + /** + * Mark a kind:445 event id as already processed so that a later relay + * echo of the same event is treated as a [GroupEventResult.Duplicate] + * instead of being re-applied. + * + * Callers should invoke this right after publishing a commit (e.g. from + * [com.vitorpamplona.amethyst.commons.marmot.MarmotManager.addMember]) + * because `group.addMember` / `group.commit` have already advanced the + * local epoch. Reprocessing the same commit bytes would otherwise fail + * with a confirmation-tag / transcript mismatch. + */ + suspend fun markEventProcessed(eventId: HexKey) { + processedIdsMutex.withLock { + processedEventIds.add(eventId) + if (processedEventIds.size > MAX_PROCESSED_IDS) { + val iterator = processedEventIds.iterator() + val toRemove = processedEventIds.size - MAX_PROCESSED_IDS + repeat(toRemove) { + iterator.next() + iterator.remove() + } + } + } + } + /** * Resolve any pending commit conflicts for a given epoch. * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index cc24c4deb..74abe20fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -31,6 +31,9 @@ import com.vitorpamplona.quartz.marmot.mls.crypto.X25519 import com.vitorpamplona.quartz.marmot.mls.framing.ContentType import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage +import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage +import com.vitorpamplona.quartz.marmot.mls.framing.Sender +import com.vitorpamplona.quartz.marmot.mls.framing.SenderType import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat import com.vitorpamplona.quartz.marmot.mls.messages.Commit import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult @@ -502,7 +505,10 @@ class MlsGroup private constructor( val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray()) val newTreeHash = tree.treeHash() - val newEpoch = groupContext.epoch + 1 + val oldEpoch = groupContext.epoch + val preCommitGroupId = groupContext.groupId + val committerLeafIndex = myLeafIndex + val newEpoch = oldEpoch + 1 groupContext = groupContext.copy( @@ -544,7 +550,20 @@ class MlsGroup private constructor( sentKeys.clear() val commitBytes = commit.toTlsBytes() - return CommitResult(commitBytes, welcomeBytes, null) + val framedCommitBytes = + framePublicMessageCommit( + groupId = preCommitGroupId, + epoch = oldEpoch, + senderLeafIndex = committerLeafIndex, + commitBytes = commitBytes, + confirmationTag = confirmationTag, + ) + return CommitResult( + commitBytes = commitBytes, + welcomeBytes = welcomeBytes, + groupInfoBytes = null, + framedCommitBytes = framedCommitBytes, + ) } // --- Message Encryption --- @@ -1475,6 +1494,38 @@ class MlsGroup private constructor( private const val REUSE_GUARD_LENGTH = 4 private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001 + /** + * Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...)) + * envelope so it can be published on the wire (RFC 9420 §6 / §6.2). + * + * The receiver uses the sender's leaf index and the confirmation_tag from + * the [PublicMessage] header to drive [MlsGroup.processCommit]. The + * `signature` and `membership_tag` opaque fields are intentionally empty — + * the current implementation does not verify them on inbound commits, + * but the TLS structure must still be present so decoding succeeds. + */ + internal fun framePublicMessageCommit( + groupId: ByteArray, + epoch: Long, + senderLeafIndex: Int, + commitBytes: ByteArray, + confirmationTag: ByteArray, + ): ByteArray { + val publicMessage = + PublicMessage( + groupId = groupId, + epoch = epoch, + sender = Sender(SenderType.MEMBER, senderLeafIndex), + authenticatedData = ByteArray(0), + contentType = ContentType.COMMIT, + content = commitBytes, + signature = ByteArray(0), + confirmationTag = confirmationTag, + membershipTag = ByteArray(0), + ) + return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes() + } + /** * Build ConfirmedTranscriptHashInput (RFC 9420 Section 8.2) — static version * usable from both instance methods and companion object factory methods. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt index 6bf3e753d..a0b4d5d9d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt @@ -91,11 +91,22 @@ data class UpdatePath( /** * Result of creating a Commit: the MLS messages to distribute. + * + * [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful + * for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit] + * entry point. For on-the-wire distribution, callers MUST publish + * [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope) + * so that receivers can parse the sender's leaf index and confirmation tag. */ data class CommitResult( val commitBytes: ByteArray, val welcomeBytes: ByteArray?, val groupInfoBytes: ByteArray?, + /** + * Fully-framed commit ready for the MIP-03 outer ChaCha20 encryption. + * Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)). + */ + val framedCommitBytes: ByteArray = commitBytes, ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index 62b9c77d9..bd574472f 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -225,6 +225,87 @@ class MarmotPipelineTest { } } + @Test + fun testAddMemberCommitIsFramedAsPublicMessage() { + // Regression: addMember's outbound commit used to carry the raw Commit TLS + // bytes instead of an MlsMessage(PublicMessage(commit)) envelope, which + // caused receivers to fail parsing with "Unsupported MLS version: …" + // when the first two bytes of the Commit struct were read as the + // MlsMessage version field. + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + val group = manager.getGroup(groupId)!! + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + + val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + + // The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit)) + val framed = commitResult.framedCommitBytes + val mlsMessage = + com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(framed), + ) + assertEquals( + com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + mlsMessage.wireFormat, + ) + + val publicMessage = + com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(mlsMessage.payload), + ) + assertEquals( + com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT, + publicMessage.contentType, + ) + assertEquals( + com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER, + publicMessage.sender.senderType, + ) + assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit") + // The PublicMessage.content carries the raw Commit struct. + kotlin.test.assertContentEquals(commitResult.commitBytes, publicMessage.content) + } + } + + @Test + fun testAddMemberCommitEventDecryptsToFramedMlsMessage() { + // End-to-end variant: the kind:445 content returned by buildCommitEvent, + // when ChaCha20-Poly1305 decrypted with the current exporter key, must + // yield an MlsMessage whose wire format is PUBLIC_MESSAGE (not a raw + // Commit struct). + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + val group = manager.getGroup(groupId)!! + val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + + val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + + val outbound = MarmotOutboundProcessor(manager) + val outboundResult = outbound.buildCommitEvent(groupId, commitResult.framedCommitBytes) + val event = outboundResult.signedEvent + + val exporterKey = manager.exporterSecret(groupId) + val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey) + val mlsMessage = + com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(mlsBytes), + ) + assertEquals( + com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE, + mlsMessage.wireFormat, + ) + } + } + @Test fun testSubscriptionManagerSyncWithGroupManager() { runBlocking { From 238c9ea626bd4643be396f75ba547f7cac426c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:47:12 +0000 Subject: [PATCH 15/34] feat(live-chat): display zap receipts inline in live stream chat Subscribe to kind 9735 alongside kind 1311 against the stream's #a tag, route host-directed zaps into the LiveActivitiesChannel cache, and render them as a centered, lightning-accented row with the zapper, amount, and optional zap message. Matches zap.stream's chat behavior for NIP-53 + NIP-57 interop. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 20 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 34 ++-- .../chats/feed/types/RenderChatZap.kt | 146 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 3 +- amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 8512b8d51..305da3fcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1541,6 +1541,8 @@ object LocalCache : ILocalCache, ICacheProvider { repliesTo.forEach { it.addZap(zapRequest, note) } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) return true @@ -1549,6 +1551,24 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + private fun attachZapToLiveActivityChannel( + event: LnZapEvent, + note: Note, + relay: NormalizedRelayUrl?, + ) { + val address = + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + ?: return + + // Match zap.stream: only show zaps whose receiver is the live activity host + if (event.zappedAuthor().none { it == address.pubKeyHex }) return + + getOrCreateLiveChannel(address).addNote(note, relay) + } + fun consume( event: LnZapRequestEvent, relay: NormalizedRelayUrl?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index e2d5fac54..753f5cf90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile @@ -84,6 +85,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -109,20 +111,24 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - NormalChatNote( - baseNote, - routeForLastRead, - innerQuote, - canPreview, - parentBackgroundColor, - accountViewModel, - nav, - onWantsToReply, - onWantsToEditDraft, - onScrollToNote, - shouldHighlight, - onHighlightFinished, - ) + if (baseNote.event is LnZapEvent) { + RenderChatZap(baseNote, accountViewModel, nav) + } else { + NormalChatNote( + baseNote, + routeForLastRead, + innerQuote, + canPreview, + parentBackgroundColor, + accountViewModel, + nav, + onWantsToReply, + onWantsToEditDraft, + onScrollToNote, + shouldHighlight, + onHighlightFinished, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt new file mode 100644 index 000000000..449d500bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent + +private const val BIG_ZAP_THRESHOLD_SATS = 50_000L + +@Composable +fun RenderChatZap( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapEvent = baseNote.event as? LnZapEvent ?: return + + val card by produceState( + ZapAmountCommentNotification(user = null, comment = null, amount = null), + baseNote, + ) { + value = accountViewModel.innerDecryptAmountMessage(baseNote) ?: value + } + + val isBigZap = + remember(zapEvent) { + (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS + } + + val containerColor = + if (isBigZap) { + BitcoinOrange.copy(alpha = 0.18f) + } else { + BitcoinOrange.copy(alpha = 0.08f) + } + + val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } + + val amountText = card.amount ?: showAmountInteger(zapEvent.amount) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ZapIcon( + if (isBigZap) Size24Modifier else Size20Modifier, + BitcoinOrange, + ) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val sender = card.user + if (sender != null) { + UserPicture(sender, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + UsernameDisplay( + baseUser = sender, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = stringRes(R.string.chat_zap_anonymous), + fontWeight = FontWeight.Bold, + ) + } + + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_zap_amount_suffix, amountText), + color = BitcoinOrange, + fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, + ) + } + + card.comment?.takeIf { it.isNotBlank() }?.let { comment -> + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = comment, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 6ce98ef3b..10da3f52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -35,7 +36,7 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND), + kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.time, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b537cbcf6..63b4ad22c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ You are using a public key and public keys are read-only. Login with a Private key to be able to boost posts You are using a public key and public keys are read-only. Login with a Private key to like posts No Zap Amount Setup. Long Press to change + zapped %1$s sats + Anonymous You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow From 30646998ea165e1d482ec149b3cd8955963905a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:05:06 +0000 Subject: [PATCH 16/34] feat(communities): prefer name() tag + add edit flow Name display - CommunityCard, CommunityName spinner option, FeedFilterSpinner renderer, and DisplayFollowingCommunityInPost now all prefer the NIP-72 `name` tag and fall back to the `d` tag. Previously they rendered the raw UUID d-tag for communities created by this app, which made them show up as /n/ instead of the configured name. - DisplayCommunity is now reactive via observeNote so the chip in NoteCompose recomposes when the community definition arrives. Edit flow - New Route.EditCommunity(kind, pubKeyHex, dTag) and EditCommunityScreen that reuses the create form via a shared CommunityFormScreen composable. - NewCommunityModel gains loadFrom(CommunityDefinitionEvent) to preload name/description/rules/moderators/relays and to remember the existing d-tag + image URL. publish() reuses the existing d-tag so the replaceable kind-34550 event updates in place. - Cover preview on the form supports the already-uploaded image (AsyncImage with remove button) and falls back to the placeholder if cleared. - Owner (note author == signer) sees an "Edit" FilledTonalIconButton in LongCommunityActionOptions that opens the edit screen. - Top bar becomes "Edit Community" with a Save button when editing. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 12 ++ .../navigation/topbars/FeedFilterSpinner.kt | 6 +- .../ui/note/elements/DisplayCommunity.kt | 34 ++-- .../amethyst/ui/note/types/CommunityHeader.kt | 21 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 7 +- .../newCommunity/NewCommunityModel.kt | 68 ++++++-- .../newCommunity/NewCommunityScreen.kt | 152 +++++++++++++++--- .../nip72Communities/CommunityCard.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 10 files changed, 255 insertions(+), 50 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ba7c5b719..23ec04639 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -94,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.CommunitiesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.EditCommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity.NewCommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.LongFormPostScreen @@ -225,6 +226,7 @@ fun BuildNavigation( composableFromEnd { PollsScreen(accountViewModel, nav) } composableFromEnd { CommunitiesScreen(accountViewModel, nav) } composableFromEnd { NewCommunityScreen(accountViewModel, nav) } + composableFromEndArgs { EditCommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { BadgesScreen(accountViewModel, nav) } composableFromEnd { ProfileBadgesScreen(accountViewModel, nav) } composableFromBottomArgs { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 721b1e957..53d08be4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -49,6 +49,18 @@ sealed class Route { @Serializable object NewCommunity : Route() + @Serializable data class EditCommunity( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable object Badges : Route() @Serializable object ProfileBadges : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index cc178d3e6..dc2c775bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @@ -351,7 +352,10 @@ fun RenderOption( is CommunityName -> { val it by observeNote(option.note, accountViewModel) - Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) + val addressable = it.note as? AddressableNote + val definition = addressable?.event as? CommunityDefinitionEvent + val label = definition?.name()?.ifBlank { null } ?: addressable?.dTag() ?: "" + Text(text = "/n/$label", fontSize = Font14SP, color = MaterialTheme.colorScheme.onSurface) } is RelayName -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 68f928f2c..0ece66d12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -24,9 +24,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.ClickableTextColor import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -34,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @Composable fun DisplayFollowingCommunityInPost( @@ -42,38 +46,46 @@ fun DisplayFollowingCommunityInPost( nav: INav, ) { Column(HalfStartPadding) { - Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, nav) } + Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) } } } @Composable private fun DisplayCommunity( note: Note, + accountViewModel: AccountViewModel, nav: INav, ) { val communityTag = note.event?.communityAddress() ?: return + val communityNote = LocalCache.getOrCreateAddressableNote(communityTag) + val communityState by observeNote(communityNote, accountViewModel) + val label = communityShortLabel(communityTag, communityState.note.event as? CommunityDefinitionEvent) + ClickableTextColor( - getCommunityShortName(communityTag), + label, linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, ) { nav.nav { - note.event?.communityAddress()?.let { communityTag -> - Route.Community(communityTag.kind, communityTag.pubKeyHex, communityTag.dTag) + note.event?.communityAddress()?.let { addr -> + Route.Community(addr.kind, addr.pubKeyHex, addr.dTag) } } } } -fun getCommunityShortName(communityAddress: Address): String { - val name = - if (communityAddress.dTag.length > 10) { - communityAddress.dTag.take(10) + "..." +private fun communityShortLabel( + address: Address, + definition: CommunityDefinitionEvent?, +): String { + val raw = definition?.name()?.ifBlank { null } ?: address.dTag + val shortened = + if (raw.length > 12) { + raw.take(12) + "..." } else { - communityAddress.dTag.take(10) + raw } - - return "/n/$name" + return "/n/$shortened" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 81725b40a..f61ddf1df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton @@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction @@ -439,6 +441,9 @@ private fun LongCommunityActionOptions( nav: INav, ) { Row { + if (note.author?.pubkeyHex == accountViewModel.account.signer.pubKey) { + EditCommunityButton(note, nav) + } ShareCommunityButton(accountViewModel, note, nav) WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> if (isFollowing) { @@ -448,6 +453,22 @@ private fun LongCommunityActionOptions( } } +@Composable +fun EditCommunityButton( + note: AddressableNote, + nav: INav, +) { + FilledTonalIconButton( + onClick = { nav.nav(Route.EditCommunity(note.address)) }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + modifier = Size18Modifier, + contentDescription = stringRes(R.string.edit_community), + ) + } +} + @Composable fun WatchAddressableNoteFollows( note: AddressableNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 81cd62793..7c502ce92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf @@ -381,7 +382,11 @@ class PeopleListName( class CommunityName( val note: AddressableNote, ) : Name() { - override fun name() = "/n/${(note.dTag())}" + override fun name(): String { + val definition = note.event as? CommunityDefinitionEvent + val label = definition?.name()?.ifBlank { null } ?: note.dTag() + return "/n/$label" + } } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index 2e480fb5c..09c61bed8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import kotlinx.collections.immutable.ImmutableList @@ -61,11 +62,16 @@ data class CommunityRelayEntry( class NewCommunityModel : ViewModel() { var account: Account? = null + // When set, publish() reuses this d-tag so the kind-34550 replaceable event + // is updated in place (edit flow). Null in the create flow. + var existingDTag: String? = null + var isPublishing by mutableStateOf(false) var name by mutableStateOf("") var description by mutableStateOf("") var rules by mutableStateOf("") + var existingImageUrl by mutableStateOf(null) // Image upload state - mirrors NewBadgeModel. var multiOrchestrator by mutableStateOf(null) @@ -88,6 +94,42 @@ class NewCommunityModel : ViewModel() { this.stripMetadata = account.settings.stripLocationOnUpload } + /** + * Preloads the form with the current contents of [existing] so the user can edit + * the replaceable kind 34550 event. Keeps the original `d` tag so relays replace + * the previous version instead of creating a new community. + */ + fun loadFrom(existing: CommunityDefinitionEvent) { + existingDTag = existing.dTag() + name = existing.name().orEmpty() + description = existing.description().orEmpty() + rules = existing.rules().orEmpty() + existingImageUrl = existing.image()?.imageUrl + + val ownerKey = account?.signer?.pubKey + moderators.clear() + existing + .moderatorKeys() + .asSequence() + .filter { it != ownerKey } + .distinct() + .forEach { pubkey -> + val user = account?.cache?.getOrCreateUser(pubkey) ?: return@forEach + if (moderators.none { it.pubkeyHex == user.pubkeyHex }) { + moderators.add(user) + } + } + + relays.clear() + existing.relays().forEach { tag -> + if (relays.none { it.url == tag.url }) { + relays.add(CommunityRelayEntry(tag.url, tag.marker)) + } + } + } + + fun isEditing(): Boolean = existingDTag != null + fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0] fun setPickedMedia(uris: ImmutableList) { @@ -156,15 +198,15 @@ class NewCommunityModel : ViewModel() { viewModelScope.launch(Dispatchers.IO) { isPublishing = true try { - val imageUrl = - uploadImageIfAny(context, myAccount, onError) ?: run { - if (multiOrchestrator != null) { - // upload failed; error was reported, bail out - return@launch - } + val uploadedUrl = + if (multiOrchestrator != null) { + uploadImageIfAny(context, myAccount, onError) ?: return@launch + } else { null } + val imageUrl = uploadedUrl ?: existingImageUrl + val ownerKey = myAccount.signer.pubKey val moderatorTags = buildList { @@ -177,6 +219,8 @@ class NewCommunityModel : ViewModel() { val relayTags = relays.map { RelayTag(it.url, it.marker) } + val dTag = existingDTag ?: Uuid.random().toString() + val definition = myAccount.sendCommunityDefinition( name = name.trim(), @@ -185,7 +229,7 @@ class NewCommunityModel : ViewModel() { image = imageUrl, rules = rules.trim().ifBlank { null }, relays = relayTags.ifEmpty { null }, - dTag = Uuid.random().toString(), + dTag = dTag, ) if (definition == null) { @@ -196,9 +240,11 @@ class NewCommunityModel : ViewModel() { return@launch } - // Follow it so it shows up under "Mine". - val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) - myAccount.follow(communityNote) + // Auto-follow only on the create flow; editing doesn't change the follow set. + if (existingDTag == null) { + val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address()) + myAccount.follow(communityNote) + } selectedServer?.let { myAccount.settings.changeDefaultFileServer(it) } myAccount.settings.changeStripLocationOnUpload(stripMetadata) @@ -260,6 +306,8 @@ class NewCommunityModel : ViewModel() { name = "" description = "" rules = "" + existingImageUrl = null + existingDTag = null multiOrchestrator = null moderators.clear() relays.clear() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index c68d871d7..bef4e8283 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -71,14 +71,17 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ActionTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList @@ -89,14 +92,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditF import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag import kotlinx.collections.immutable.persistentListOf -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewCommunityScreen( accountViewModel: AccountViewModel, nav: INav, +) = CommunityFormScreen(editing = null, accountViewModel = accountViewModel, nav = nav) + +@Composable +fun EditCommunityScreen( + editing: Address, + accountViewModel: AccountViewModel, + nav: INav, +) = CommunityFormScreen(editing = editing, accountViewModel = accountViewModel, nav = nav) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CommunityFormScreen( + editing: Address?, + accountViewModel: AccountViewModel, + nav: INav, ) { val model: NewCommunityModel = viewModel() val context = LocalContext.current @@ -105,6 +124,15 @@ fun NewCommunityScreen( model.init(accountViewModel.account) } + LaunchedEffect(editing) { + if (editing != null) { + val note = LocalCache.getOrCreateAddressableNote(editing) + (note.event as? CommunityDefinitionEvent)?.let { model.loadFrom(it) } + } else { + model.existingDTag = null + } + } + StrippingFailureDialog(model.strippingFailureConfirmation) var wantsToPickImage by remember { mutableStateOf(false) } @@ -122,21 +150,42 @@ fun NewCommunityScreen( Scaffold( topBar = { - CreatingTopBar( - titleRes = R.string.new_community, - isActive = model::canPost, - onCancel = { - model.reset() - nav.popBack() - }, - onPost = { - model.publish( - context = context, - onSuccess = { nav.popBack() }, - onError = accountViewModel.toastManager::toast, - ) - }, - ) + val titleRes = + if (model.isEditing()) R.string.edit_community else R.string.new_community + if (model.isEditing()) { + ActionTopBar( + titleRes = titleRes, + postRes = R.string.save, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + } else { + CreatingTopBar( + titleRes = titleRes, + isActive = model::canPost, + onCancel = { + model.reset() + nav.popBack() + }, + onPost = { + model.publish( + context = context, + onSuccess = { nav.popBack() }, + onError = accountViewModel.toastManager::toast, + ) + }, + ) + } }, ) { pad -> Surface( @@ -235,18 +284,69 @@ private fun CommunityImagePicker( accountViewModel: AccountViewModel, onPickImage: () -> Unit, ) { - if (model.hasPickedImage()) { - model.multiOrchestrator?.let { - Box(modifier = Modifier.clickable(onClick = onPickImage)) { - ShowImageUploadGallery( - list = it, - onDelete = { model.setPickedMedia(persistentListOf()) }, - accountViewModel = accountViewModel, - ) + val existingUrl = model.existingImageUrl + when { + model.hasPickedImage() -> { + model.multiOrchestrator?.let { + Box(modifier = Modifier.clickable(onClick = onPickImage)) { + ShowImageUploadGallery( + list = it, + onDelete = { model.setPickedMedia(persistentListOf()) }, + accountViewModel = accountViewModel, + ) + } } } - } else { - CommunityImagePlaceholder(onClick = onPickImage) + + !existingUrl.isNullOrBlank() -> { + ExistingCommunityCover( + url = existingUrl, + onClick = onPickImage, + onClear = { model.existingImageUrl = null }, + ) + } + + else -> { + CommunityImagePlaceholder(onClick = onPickImage) + } + } +} + +@Composable +private fun ExistingCommunityCover( + url: String, + onClick: () -> Unit, + onClear: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clickable(onClick = onClick), + contentAlignment = Alignment.TopEnd, + ) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = androidx.compose.ui.layout.ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(16.dp), + ), + ) + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringRes(R.string.remove), + tint = MaterialTheme.colorScheme.onSurface, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index fe246d8b6..266d8f9ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -96,7 +96,7 @@ fun RenderCommunitiesThumb( RenderCommunitiesThumb( CommunityCard( - name = noteEvent.dTag(), + name = noteEvent.name()?.ifBlank { null } ?: noteEvent.dTag(), description = noteEvent.description(), cover = noteEvent.image()?.imageUrl, moderators = noteEvent.moderatorKeys().toImmutableList(), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7474e1f40..51e4f0fab 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -422,6 +422,7 @@ Badges Communities New Community + Edit Community Community name Description Rules (optional) From 868e674a1cdb2593c30cdb37a806d1a3cb944ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:25:30 +0000 Subject: [PATCH 17/34] perf(communities): stop rebuilding the card on every reaction/zap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderCommunitiesThumb was observing the full NoteState, which recomposes on every reaction, zap, boost or metadata tick. Each recomposition rebuilt the CommunityCard, re-parsing the definition tags and calling moderatorKeys().toImmutableList() — producing a brand-new ImmutableList instance. That new list identity then invalidated LaunchedEffect(key = moderators) in LoadModerators, re-running the expensive ParticipantListBuilder traversal (author + all replies + boosts + zaps + reactions + public-chat notes) per tick. Fixes: - Switch to observeNoteEvent so the thumb only recomposes when the definition event itself changes (including edits). Reactions/zaps are handled separately inside LikeReaction/ZapReaction. - remember(event.id) { CommunityCard(...) } so the card and its moderator list identity are allocated once per event version. - Stabilize LoadModerators' LaunchedEffect with (baseNote.idHex, moderators). Convert the hosts list to a Set for O(1) filtering, reuse a single ParticipantListBuilder instance, and skip the redundant .minus(hosts) call on the new set. --- .../nip72Communities/CommunityCard.kt | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt index 266d8f9ba..c1478ea52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/CommunityCard.kt @@ -54,7 +54,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByPr import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner @@ -91,16 +91,26 @@ fun RenderCommunitiesThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteState by observeNote(baseNote, accountViewModel) - val noteEvent = noteState.note.event as? CommunityDefinitionEvent ?: return + // Narrow observation: we only care about the CommunityDefinitionEvent itself, + // not every reaction/zap/boost that would otherwise recompose the whole card. + val definition by observeNoteEvent(baseNote, accountViewModel) + val event = definition ?: return + + // Memoize card + moderator list identity so LaunchedEffect(moderators) in + // LoadModerators doesn't retrigger the ParticipantListBuilder traversal on + // every recomposition. + val card = + remember(event.id) { + CommunityCard( + name = event.name()?.ifBlank { null } ?: event.dTag(), + description = event.description(), + cover = event.image()?.imageUrl, + moderators = event.moderatorKeys().toImmutableList(), + ) + } RenderCommunitiesThumb( - CommunityCard( - name = noteEvent.name()?.ifBlank { null } ?: noteEvent.dTag(), - description = noteEvent.description(), - cover = noteEvent.image()?.imageUrl, - moderators = noteEvent.moderatorKeys().toImmutableList(), - ), + card, baseNote, accountViewModel, nav, @@ -193,16 +203,21 @@ fun LoadModerators( ) } - LaunchedEffect(key1 = moderators) { + // Keying by the note id + moderator identity keeps this effect from + // restarting on every recomposition (reaction/zap updates would otherwise + // reallocate `moderators` and rerun the expensive traversal below). + LaunchedEffect(key1 = baseNote.idHex, key2 = moderators) { launch(Dispatchers.IO) { + val authorHex = baseNote.author?.pubkeyHex val hosts = moderators.mapNotNull { part -> - if (part != baseNote.author?.pubkeyHex) { + if (part != authorHex) { LocalCache.checkGetOrCreateUser(part) } else { null } } + val hostSet = hosts.toSet() val topFilter = accountViewModel.account.liveDiscoveryFollowLists.value val discoveryTopFilterAuthors = @@ -217,15 +232,15 @@ fun LoadModerators( else -> null } - val followingKeySet = discoveryTopFilterAuthors + val builder = ParticipantListBuilder() val allParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts) + builder.followsThatParticipateOn(baseNote, discoveryTopFilterAuthors) - hostSet val newParticipantUsers = - if (followingKeySet == null) { + if (discoveryTopFilterAuthors == null) { val allFollows = accountViewModel.account.kind3FollowList.flow.value.authors val followingParticipants = - ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts) + builder.followsThatParticipateOn(baseNote, allFollows) - hostSet (hosts + followingParticipants + (allParticipants - followingParticipants)) .toImmutableList() From 4786647762b89f6afae42fd6928a70119b99f877 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:29:50 +0000 Subject: [PATCH 18/34] feat(live-chat): display live stream raids (kind 1312) inline Add a Quartz LiveActivitiesRaidEvent for zap.stream's kind 1312 raid convention with root/mention a-tag markers. Wire it into the event factory, subscribe to it in the live-activity chat filter, and route received raids into both source and target LiveActivitiesChannel caches so viewers on either side see the notification. Render raids as an accent-colored pill showing "X is raiding Y" that navigates to the target stream when tapped. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 22 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 7 +- .../chats/feed/types/RenderChatRaid.kt | 147 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../raid/LiveActivitiesRaidEvent.kt | 115 ++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 7 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 305da3fcf..124d7db39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -172,6 +172,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -1495,6 +1496,26 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + fun consume( + event: LiveActivitiesRaidEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val fromAddress = event.fromAddress() + val toAddress = event.toAddress() + if (fromAddress == null && toAddress == null) return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val note = getOrCreateNote(event.id) + fromAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + toAddress?.let { getOrCreateLiveChannel(it).addNote(note, relay) } + } + + return new + } + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2667,6 +2688,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) + is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 753f5cf90..fa5e645ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent @@ -85,6 +86,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount @@ -111,8 +113,11 @@ fun ChatroomMessageCompose( accountViewModel = accountViewModel, nav = nav, ) { canPreview -> - if (baseNote.event is LnZapEvent) { + val event = baseNote.event + if (event is LnZapEvent) { RenderChatZap(baseNote, accountViewModel, nav) + } else if (event is LiveActivitiesRaidEvent) { + RenderChatRaid(baseNote, accountViewModel, nav) } else { NormalChatNote( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt new file mode 100644 index 000000000..00b0b6f31 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent + +@Composable +fun RenderChatRaid( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val raid = baseNote.event as? LiveActivitiesRaidEvent ?: return + val from = remember(raid) { raid.fromAddress() } + val to = remember(raid) { raid.toAddress() } + + // Without both endpoints we can't render a meaningful raid card. + if (from == null || to == null) return + + val accent = MaterialTheme.colorScheme.primary + val containerColor = remember(accent) { accent.copy(alpha = 0.14f) } + val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .clickable { nav.nav(to.toRoute()) } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_raid_is_raiding), + color = accent, + fontWeight = FontWeight.Bold, + ) + Spacer(StdHorzSpacer) + + UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + } + + val message = raid.content + if (message.isNotBlank()) { + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = message, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } + } + + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = accent, + modifier = Size20Modifier, + ) + } +} + +private fun Address.toRoute(): Route = Route.LiveActivityChannel(kind, pubKeyHex, dTag) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 10da3f52c..15fd2035c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent fun filterMessagesToLiveActivities( @@ -36,7 +37,12 @@ fun filterMessagesToLiveActivities( relay = it, filter = Filter( - kinds = listOf(LiveActivitiesChatMessageEvent.KIND, LnZapEvent.KIND), + kinds = + listOf( + LiveActivitiesChatMessageEvent.KIND, + LiveActivitiesRaidEvent.KIND, + LnZapEvent.KIND, + ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, since = since?.get(it)?.time, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 63b4ad22c..2a2eb75d0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ No Zap Amount Setup. Long Press to change zapped %1$s sats Anonymous + is raiding You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt new file mode 100644 index 000000000..cbf9f605e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEvent.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.raid + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-53 live stream raid (zap.stream convention, kind 1312). + * + * A raid is authored by the source streamer to redirect viewers to another live + * stream. The event carries two `a` tags referencing NIP-53 Live Activities + * (kind 30311) differentiated by NIP-10-style markers at position 3: + * - "root" -> the source stream (the raid is being sent FROM) + * - "mention" -> the target stream (the raid is being sent TO) + * + * The `content` is a free-text raid message from the source streamer. + */ +@Immutable +class LiveActivitiesRaidEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + fun fromActivity(): ATag? = findActivity(MARKER_ROOT) + + fun toActivity(): ATag? = findActivity(MARKER_MENTION) + + fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + private fun findActivity(marker: String): ATag? = + tags + .asSequence() + .filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker } + .mapNotNull(ATag::parse) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + + companion object { + const val KIND = 1312 + const val ALT = "Live activity raid" + const val MARKER_ROOT = "root" + const val MARKER_MENTION = "mention" + + /** + * Builds an event template for a raid. Sender must be the host of the `from` stream. + * + * @param from source stream (the one currently live that is being ended/redirected) + * @param to target stream (where viewers should be redirected) + * @param message raid announcement text + */ + fun build( + from: EventHintBundle, + to: EventHintBundle, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + addMarkedATag(from, MARKER_ROOT) + addMarkedATag(to, MARKER_MENTION) + initializer() + } + + private fun TagArrayBuilder.addMarkedATag( + bundle: EventHintBundle, + marker: String, + ) { + val relayUrl = bundle.relay?.url.orEmpty() + val addressId = + Address.assemble( + LiveActivitiesEvent.KIND, + bundle.event.pubKey, + bundle.event.dTag(), + ) + add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker)) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8c46307f2..c4d721624 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -168,6 +168,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent @@ -437,6 +438,7 @@ class EventFactory { LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig) From 81ab25fbbcbf1aca9de3c4715a6d3e6f1940b8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:40:27 +0000 Subject: [PATCH 19/34] fix(communities): always show Save on the edit screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit/create top bar was keyed on model.isEditing(), which reads existingDTag set by loadFrom(). loadFrom runs in a LaunchedEffect after the first composition, so the first render of the edit screen briefly showed the "Create" button before flipping to "Save". Derive the top bar from the `editing: Address?` parameter instead — it is known synchronously at composition, so edit mode shows "Save" on frame one. --- .../communities/newCommunity/NewCommunityScreen.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index bef4e8283..456a577c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -150,9 +150,12 @@ private fun CommunityFormScreen( Scaffold( topBar = { - val titleRes = - if (model.isEditing()) R.string.edit_community else R.string.new_community - if (model.isEditing()) { + // Derive the top bar from the parameter, not model.isEditing(), so the + // first composition of the edit screen already shows "Save" before + // loadFrom() runs in its LaunchedEffect. + val isEditing = editing != null + val titleRes = if (isEditing) R.string.edit_community else R.string.new_community + if (isEditing) { ActionTopBar( titleRes = titleRes, postRes = R.string.save, From 8368c9d66130806c335c9510caca9de9dc55be78 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:53:02 +0000 Subject: [PATCH 20/34] feat(live-chat): display live stream clips (kind 1313) inline Add a Quartz LiveActivitiesClipEvent for zap.stream's kind 1313 clip convention, parsing the `a` tag (stream address), `p` tag (host), `r` tag (video URL), and `title` tag. Register it in EventFactory, subscribe in the live-activity chat filter, and route clips into the source LiveActivitiesChannel cache. Render clips as an accent-colored card showing "X created a clip" with the title and an inline video player for the clip's playable URL. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 20 +++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 4 + .../chats/feed/types/RenderChatClip.kt | 119 ++++++++++++++++++ .../FilterMessagesToLiveStream.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + .../clip/LiveActivitiesClipEvent.kt | 114 +++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 7 files changed, 262 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 124d7db39..41f7a8fb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -169,6 +169,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent @@ -1516,6 +1517,24 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + fun consume( + event: LiveActivitiesClipEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val activityAddress = event.activityAddress() ?: return false + + val new = consumeRegularEvent(event, relay, wasVerified) + + if (new) { + val channel = getOrCreateLiveChannel(activityAddress) + val note = getOrCreateNote(event.id) + channel.addNote(note, relay) + } + + return new + } + @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2689,6 +2708,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) is LiveActivitiesRaidEvent -> consume(event, relay, wasVerified) + is LiveActivitiesClipEvent -> consume(event, relay, wasVerified) is MeetingSpaceEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomEvent -> consumeBaseReplaceable(event, relay, wasVerified) is MeetingRoomPresenceEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index fa5e645ce..d01995e59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote @@ -86,6 +87,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -118,6 +120,8 @@ fun ChatroomMessageCompose( RenderChatZap(baseNote, accountViewModel, nav) } else if (event is LiveActivitiesRaidEvent) { RenderChatRaid(baseNote, accountViewModel, nav) + } else if (event is LiveActivitiesClipEvent) { + RenderChatClip(baseNote, accountViewModel, nav) } else { NormalChatNote( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt new file mode 100644 index 000000000..21edb4767 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.VideoView +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent + +@Composable +fun RenderChatClip( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val clip = baseNote.event as? LiveActivitiesClipEvent ?: return + + val videoUrl = remember(clip) { clip.videoUrl() } + val title = remember(clip) { clip.title() } + val authorHex = remember(clip) { clip.pubKey } + + val accent = MaterialTheme.colorScheme.primary + val containerColor = remember(accent) { accent.copy(alpha = 0.12f) } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(containerColor) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_clip_created_a_clip), + color = accent, + fontWeight = FontWeight.Bold, + ) + } + + if (!title.isNullOrBlank()) { + Text(text = title) + } + + val caption = clip.content + if (caption.isNotBlank()) { + Text(text = caption) + } + + if (!videoUrl.isNullOrBlank()) { + VideoView( + videoUri = videoUrl, + mimeType = null, + title = title, + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index 15fd2035c..c1c47325a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -41,6 +42,7 @@ fun filterMessagesToLiveActivities( listOf( LiveActivitiesChatMessageEvent.KIND, LiveActivitiesRaidEvent.KIND, + LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2a2eb75d0..3e0f7e1c7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -56,6 +56,7 @@ zapped %1$s sats Anonymous is raiding + created a clip You are using a public key and public keys are read-only. Login with a Private key to be able to send zaps You are using a public key and public keys are read-only. Login with a Private key to be able to follow You are using a public key and public keys are read-only. Login with a Private key to be able to unfollow diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt new file mode 100644 index 000000000..32bb28397 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEvent.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.clip + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag +import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-53 live stream clip (zap.stream convention, kind 1313). + * + * A clip is a standalone highlight produced from an ongoing or past live stream. + * The event carries: + * - `a` -> the source stream address (kind 30311) + * - `p` -> the stream host's pubkey + * - `r` -> direct playable video URL (MP4/HLS) + * - `title` -> clip title + * - `alt` -> NIP-31 fallback text + * + * `content` is an optional free-text caption. + */ +@Immutable +class LiveActivitiesClipEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + PubKeyHintProvider { + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + + override fun pubKeyHints(): List = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys(): List = tags.mapNotNull(PTag::parseKey) + + fun activity(): ATag? = + tags + .asSequence() + .mapNotNull(ATag::parse) + .firstOrNull { it.kind == LiveActivitiesEvent.KIND } + + fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) } + + fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey) + + fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse) + + fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse) + + companion object { + const val KIND = 1313 + const val ALT = "Live activity clip" + + /** + * Builds an event template for a clip. Typically published by the clip-authoring backend + * on behalf of a viewer, but can also be published directly by a client. + */ + fun build( + activity: EventHintBundle, + videoUrl: String, + title: String, + host: HexKey = activity.event.pubKey, + caption: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, caption, createdAt) { + aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay)) + add(PTag.assemble(host, null)) + add(ReferenceTag.assemble(videoUrl)) + add(TitleTag.assemble(title)) + add(AltTag.assemble(ALT)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c4d721624..c22b0e799 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -165,6 +165,7 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent @@ -437,6 +438,7 @@ class EventFactory { KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig) LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig) + LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig) LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) From 43435427b2254b08cff4f7fedc5e46c198b5e6e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:10:54 +0000 Subject: [PATCH 21/34] feat(live-chat): show NIP-75 zap goal at top of live stream chat Add a goalEventId() accessor on LiveActivitiesEvent that reads the zap.stream `["goal", ""]` tag. When a stream channel's 30311 event references a goal, fetch the kind 9041 event and its zap receipts (#e=) so the existing goal-progress aggregation works. Render a compact LiveStreamGoalHeader above the chat feed showing the goal title, the existing GoalProgressBar, and a one-tap ZapReaction that targets the goal event so viewers can contribute directly to the fundraiser without leaving the stream. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../ChannelPublicFilterSubAssembler.kt | 20 ++- .../FilterGoalForLiveActivity.kt | 66 +++++++++ .../nip53LiveActivities/ChannelView.kt | 2 + .../header/LiveStreamGoalHeader.kt | 131 ++++++++++++++++++ .../streaming/LiveActivitiesEvent.kt | 7 + 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt index 6fab0c332..0daf59b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/ChannelPublicFilterSubAssembler.kt @@ -39,10 +39,22 @@ class ChannelPublicFilterSubAssembler( since: SincePerRelayMap?, ): List? = when (val channel = key.channel) { - is EphemeralChatChannel -> filterMessagesToEphemeralChat(channel, since) - is PublicChatChannel -> filterMessagesToPublicChat(channel, since) - is LiveActivitiesChannel -> filterMessagesToLiveActivities(channel, since) - else -> null + is EphemeralChatChannel -> { + filterMessagesToEphemeralChat(channel, since) + } + + is PublicChatChannel -> { + filterMessagesToPublicChat(channel, since) + } + + is LiveActivitiesChannel -> { + filterMessagesToLiveActivities(channel, since) + + filterGoalForLiveActivities(channel, since) + } + + else -> { + null + } } override fun id(key: ChannelQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt new file mode 100644 index 000000000..427101e5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies + +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent + +/** + * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts + * that count toward it. + * + * zap.stream attaches a goal to a 30311 event via `["goal", ""]`. + * We fetch the 9041 goal event by id and the 9735 zap receipts that `#e`-tag it. + */ +fun filterGoalForLiveActivities( + channel: LiveActivitiesChannel, + since: SincePerRelayMap?, +): List { + val goalId = channel.info?.goalEventId() ?: return emptyList() + + return channel.relays().toSet().flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(GoalEvent.KIND), + ids = listOf(goalId), + limit = 1, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LnZapEvent.KIND), + tags = mapOf("e" to listOf(goalId)), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index e84895170..1e210886c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -127,6 +128,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt new file mode 100644 index 000000000..794281aa1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamGoalHeader.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.types.GoalProgressBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent + +/** + * Compact header rendered above the live-activity chat feed when the stream + * has a NIP-75 zap goal attached. Shows the goal title, a progress bar, and + * a one-tap zap button targeting the goal event. + */ +@Composable +fun LiveStreamGoalHeader( + channel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goalId = channel.info?.goalEventId() ?: return + + LoadNote(baseNoteHex = goalId, accountViewModel = accountViewModel) { goalNote -> + if (goalNote != null) { + GoalHeaderContent(goalNote, accountViewModel, nav) + } + } +} + +@Composable +private fun GoalHeaderContent( + goalNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goal = goalNote.event as? GoalEvent ?: return + + val title = + remember(goal) { + goal.summary()?.ifBlank { null } ?: goal.content.take(120).ifBlank { null } + } + val goalAmountSats = remember(goal) { (goal.amount() ?: 0L) / 1000 } + + if (goalAmountSats <= 0) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + + Spacer(StdHorzSpacer) + ZapReaction( + baseNote = goalNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + iconSize = Size20dp, + iconSizeModifier = Size20Modifier, + showCounter = false, + nav = nav, + ) + } + + GoalProgressBar( + note = goalNote, + goalAmountSats = goalAmountSats, + accountViewModel = accountViewModel, + ) + } + + HorizontalDivider(thickness = 0.5.dp) + Spacer(Modifier.height(2.dp)) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt index bf710592a..67754e328 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEvent.kt @@ -117,6 +117,12 @@ class LiveActivitiesEvent( fun pinned() = tags.mapNotNull(PinnedEventTag::parse) + /** + * zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream + * via a flat tag `["goal", ""]` on the 30311 event. + */ + fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1) + fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? = if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) { StatusTag.STATUS.ENDED @@ -139,6 +145,7 @@ class LiveActivitiesEvent( companion object { const val KIND = 30311 const val ALT = "Live activity event" + const val GOAL_TAG = "goal" suspend fun create( signer: NostrSigner, From 00b9d36d5d493a544093583afd4ee521baa5d635 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Apr 2026 18:13:20 -0400 Subject: [PATCH 22/34] Fixing spacing in the bottom bar for chat screens --- .../amethyst/ui/layouts/DisappearingScaffold.kt | 8 +++++++- .../loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt | 4 +--- .../ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt | 4 +--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index ec38d6635..438dd451a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -24,6 +24,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -90,12 +92,16 @@ fun DisappearingScaffold( // The outer Surface provides the Material container color + onBackground as // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text // color falls back to Color.Black and is invisible on the dark theme). - val rootModifier = + val baseModifier = if (allowBarHide) { Modifier.imePadding().nestedScroll(connection) } else { Modifier.imePadding() } + val rootModifier = + baseModifier + .let { if (topBar == null) it.statusBarsPadding() else it } + .let { if (bottomBar == null) it.navigationBarsPadding() else it } Surface( modifier = rootModifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt index 99b5e86a0..2dc1d6932 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.GroupAdd @@ -100,7 +98,7 @@ fun MarmotGroupChatScreen( accountViewModel = accountViewModel, allowBarHide = false, ) { - Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) { + Column(Modifier.padding(it)) { MarmotGroupChatView( nostrGroupId = nostrGroupId, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index a12f41959..aa67b6bf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -21,9 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -84,7 +82,7 @@ fun ChatroomScreen( accountViewModel = accountViewModel, allowBarHide = false, ) { - Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) { + Column(Modifier.padding(it)) { ChatroomView( room = roomId, draftMessage = draftMessage, From 003603fd81aea2505d63e9c48bcf8a4f5f22bc61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:09:10 +0000 Subject: [PATCH 23/34] feat(live-chat): top zappers leaderboard above live stream chat Add a horizontally-scrollable strip of pill chips showing the top zappers on a live stream, placed above the goal header. Each chip has an avatar, lightning icon, and compact sat total (K/M), matching zap.stream's TopZappers look. Tapping a chip opens the zapper's profile. Aggregates zaps from two sources and dedupes by receipt id so a zap that tags both #a= and #e= doesn't double-count: - stream-scoped host-directed zaps already routed into the LiveActivitiesChannel cache via the existing #a subscription - goal-scoped zaps reachable through goalNote.zaps when a NIP-75 goal is attached Sorts contributors by total sats desc, takes top 10, hides the row when there are none. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../nip53LiveActivities/ChannelView.kt | 2 + .../header/LiveStreamTopZappers.kt | 208 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt index 1e210886c..344403048 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ChannelView.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamGoalHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveStreamTopZappers import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -128,6 +129,7 @@ fun LiveActivityChannelView( .weight(1f, true), ) { ShowVideoStreaming(channel, accountViewModel) + LiveStreamTopZappers(channel, accountViewModel, nav) LiveStreamGoalHeader(channel, accountViewModel, nav) RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt new file mode 100644 index 000000000..1437b5312 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import java.math.BigDecimal + +private const val TOP_ZAPPERS_LIMIT = 10 + +private data class TopZapperEntry( + val zapperPubKey: HexKey, + val totalSats: BigDecimal, +) + +/** + * Horizontally-scrollable leaderboard of the top zappers on a live stream. + * Aggregates zaps from both the stream's #a subscription and the attached + * NIP-75 zap goal (if any), de-duplicating by zap-receipt id. Matches + * zap.stream's TopZappers UX: pill chips with avatar + lightning + sats. + */ +@Composable +fun LiveStreamTopZappers( + channel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val goalId = channel.info?.goalEventId() + + if (goalId != null) { + var goalNote by remember(goalId) { mutableStateOf(accountViewModel.getNoteIfExists(goalId)) } + if (goalNote == null) { + LaunchedEffect(goalId) { + goalNote = accountViewModel.checkGetOrCreateNote(goalId) + } + } + TopZappersStrip(channel, goalNote, accountViewModel, nav) + } else { + TopZappersStrip(channel, null, accountViewModel, nav) + } +} + +@Composable +private fun TopZappersStrip( + channel: LiveActivitiesChannel, + goalNote: Note?, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Trigger recomposition whenever a new zap lands in the channel cache. + val channelTick by channel.changesFlow().collectAsStateWithLifecycle(initialValue = null) + + // Trigger recomposition when zaps arrive for the goal note. + val goalZapsState = + if (goalNote != null) { + observeNoteZaps(goalNote, accountViewModel).value + } else { + null + } + + val entries = + remember(channel, channelTick, goalNote, goalZapsState) { + aggregateTopZappers(channel, goalNote) + } + + if (entries.isEmpty()) return + + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(horizontal = 4.dp), + ) { + items(entries, key = { it.zapperPubKey }) { entry -> + ZapperPill(entry, accountViewModel, nav) + } + } +} + +@Composable +private fun ZapperPill( + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = + Modifier.clickable { + nav.nav(Route.Profile(entry.zapperPubKey)) + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + UserPicture( + userHex = entry.zapperPubKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(entry.totalSats), + style = MaterialTheme.typography.labelMedium, + ) + Spacer(Modifier.padding(start = 2.dp)) + } + } +} + +private fun aggregateTopZappers( + channel: LiveActivitiesChannel, + goalNote: Note?, +): List { + // receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e. + val byReceipt = HashMap>() + + // Stream-level zaps routed to the channel cache. + channel.notes.forEach { _, note -> + val ev = note.event + if (ev is LnZapEvent) { + val zapper = ev.zapRequest?.pubKey ?: return@forEach + val sats = ev.amount() ?: return@forEach + byReceipt[note.idHex] = zapper to sats + } + } + + // Goal-scoped zaps attached to the goal note via #e. + goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> + val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach + val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach + val sats = receiptEv.amount() ?: return@forEach + byReceipt[receiptNote.idHex] = zapper to sats + } + + if (byReceipt.isEmpty()) return emptyList() + + val totals = HashMap(byReceipt.size) + byReceipt.values.forEach { (pk, sats) -> + totals[pk] = (totals[pk] ?: BigDecimal.ZERO) + sats + } + + return totals.entries + .asSequence() + .sortedByDescending { it.value } + .take(TOP_ZAPPERS_LIMIT) + .map { TopZapperEntry(it.key, it.value) } + .toList() +} From 766274d0b2b11d72969746a45ee98d4849847c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:29:05 +0000 Subject: [PATCH 24/34] refactor(live-chat): polish NIP-53 chat renderers from self-review - Extract a shared StreamSystemCard composable for the rounded accent container used by zap, raid, and clip cards so they share one visual language. - Unify clip caption rendering through CrossfadeToDisplayComment so captions get the same NIP-19 / emoji treatment as zap and raid messages. - Iterate every `a` tag when routing zap receipts into live-activity channels so a receipt referencing multiple simulcasted streams lands in each of them, and fold the host match into the same pass. - Bucket anonymous and private zaps under a single sentinel key in the Top Zappers aggregator so an "Anonymous" chip shows once with the combined total instead of one chip per one-time pubkey. - Add commonTest coverage for LiveActivitiesRaidEvent marker parsing, LiveActivitiesClipEvent tag parsing, and LiveActivitiesEvent.goalEventId(). https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 23 ++-- .../chats/feed/types/RenderChatClip.kt | 96 +++++++------- .../chats/feed/types/RenderChatRaid.kt | 121 +++++++++--------- .../chats/feed/types/RenderChatZap.kt | 100 +++++++-------- .../chats/feed/types/StreamSystemCard.kt | 65 ++++++++++ .../header/LiveStreamTopZappers.kt | 52 +++++--- .../clip/LiveActivitiesClipEventTest.kt | 85 ++++++++++++ .../raid/LiveActivitiesRaidEventTest.kt | 85 ++++++++++++ .../LiveActivitiesEventGoalTagTest.kt | 85 ++++++++++++ 9 files changed, 516 insertions(+), 196 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 41f7a8fb3..c58e725ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1596,17 +1596,20 @@ object LocalCache : ILocalCache, ICacheProvider { note: Note, relay: NormalizedRelayUrl?, ) { - val address = - event.tags - .asSequence() - .mapNotNull(ATag::parseAddress) - .firstOrNull { it.kind == LiveActivitiesEvent.KIND } - ?: return + // Match zap.stream: only show zaps whose receiver is the live activity host. + val hosts = event.zappedAuthor().toHashSet() + if (hosts.isEmpty()) return - // Match zap.stream: only show zaps whose receiver is the live activity host - if (event.zappedAuthor().none { it == address.pubKeyHex }) return - - getOrCreateLiveChannel(address).addNote(note, relay) + // Route into every live-activity address this zap references (zap.stream uses one, but + // a receipt could legitimately reference multiple simulcasted streams). + event.tags + .asSequence() + .mapNotNull(ATag::parseAddress) + .filter { it.kind == LiveActivitiesEvent.KIND && it.pubKeyHex in hosts } + .distinct() + .forEach { address -> + getOrCreateLiveChannel(address).addNote(note, relay) + } } fun consume( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt index 21edb4767..4561d7025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -20,21 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -42,6 +39,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,56 +62,56 @@ fun RenderChatClip( val authorHex = remember(clip) { clip.pubKey } val accent = MaterialTheme.colorScheme.primary - val containerColor = remember(accent) { accent.copy(alpha = 0.12f) } + val accentAlpha = 0.12f - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) - .background(containerColor) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) - Spacer(StdHorzSpacer) - LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user -> - if (user != null) { - UsernameDisplay( - baseUser = user, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) + StreamSystemCard(accent = accent, accentAlpha = accentAlpha) { + val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = accentAlpha)) } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(authorHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = authorHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } } + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_clip_created_a_clip), + color = accent, + fontWeight = FontWeight.Bold, + ) } - Spacer(StdHorzSpacer) - Text( - text = stringRes(R.string.chat_clip_created_a_clip), - color = accent, - fontWeight = FontWeight.Bold, - ) - } - if (!title.isNullOrBlank()) { - Text(text = title) - } + if (!title.isNullOrBlank()) { + Text(text = title) + } - val caption = clip.content - if (caption.isNotBlank()) { - Text(text = caption) - } + val caption = clip.content + if (caption.isNotBlank()) { + CrossfadeToDisplayComment( + comment = caption, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } - if (!videoUrl.isNullOrBlank()) { - VideoView( - videoUri = videoUrl, - mimeType = null, - title = title, - roundedCorner = true, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) + if (!videoUrl.isNullOrBlank()) { + VideoView( + videoUri = videoUrl, + mimeType = null, + title = title, + roundedCorner = true, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt index 00b0b6f31..3bc3d1bf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -20,15 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos import androidx.compose.material3.Icon @@ -40,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -73,74 +68,72 @@ fun RenderChatRaid( if (from == null || to == null) return val accent = MaterialTheme.colorScheme.primary - val containerColor = remember(accent) { accent.copy(alpha = 0.14f) } - val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) - .background(containerColor) - .clickable { nav.nav(to.toRoute()) } - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp), + StreamSystemCard( + accent = accent, + accentAlpha = 0.14f, + onClick = { nav.nav(to.toRoute()) }, ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) - Spacer(StdHorzSpacer) - LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> - if (user != null) { - UsernameDisplay( - baseUser = user, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) + val backgroundColor = remember(accent) { mutableStateOf(accent.copy(alpha = 0.14f)) } + + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserPicture(from.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = from.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } + } + + Spacer(StdHorzSpacer) + Text( + text = stringRes(R.string.chat_raid_is_raiding), + color = accent, + fontWeight = FontWeight.Bold, + ) + Spacer(StdHorzSpacer) + + UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } } } - Spacer(StdHorzSpacer) - Text( - text = stringRes(R.string.chat_raid_is_raiding), - color = accent, - fontWeight = FontWeight.Bold, - ) - Spacer(StdHorzSpacer) - - UserPicture(to.pubKeyHex, Size20dp, Modifier, accountViewModel, nav) - Spacer(StdHorzSpacer) - LoadUser(baseUserHex = to.pubKeyHex, accountViewModel = accountViewModel) { user -> - if (user != null) { - UsernameDisplay( - baseUser = user, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) - } + val message = raid.content + if (message.isNotBlank()) { + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = message, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) } } - val message = raid.content - if (message.isNotBlank()) { - Spacer(Modifier.padding(top = 2.dp)) - CrossfadeToDisplayComment( - comment = message, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel, - ) - } + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = accent, + modifier = Size20Modifier, + ) } - - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, - contentDescription = null, - tint = accent, - modifier = Size20Modifier, - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt index 449d500bc..b066890b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -20,14 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -36,7 +33,6 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R @@ -79,67 +75,57 @@ fun RenderChatZap( (zapEvent.amount?.toLong() ?: 0L) >= BIG_ZAP_THRESHOLD_SATS } - val containerColor = - if (isBigZap) { - BitcoinOrange.copy(alpha = 0.18f) - } else { - BitcoinOrange.copy(alpha = 0.08f) - } - - val backgroundColor = remember(containerColor) { mutableStateOf(containerColor) } - + val accentAlpha = if (isBigZap) 0.18f else 0.08f val amountText = card.amount ?: showAmountInteger(zapEvent.amount) - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) - .clip(RoundedCornerShape(8.dp)) - .background(containerColor) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ZapIcon( - if (isBigZap) Size24Modifier else Size20Modifier, - BitcoinOrange, - ) + StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) { + val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) } + + Row( + modifier = Modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ZapIcon( + if (isBigZap) Size24Modifier else Size20Modifier, + BitcoinOrange, + ) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val sender = card.user + if (sender != null) { + UserPicture(sender, Size20dp, Modifier, accountViewModel, nav) + Spacer(StdHorzSpacer) + UsernameDisplay( + baseUser = sender, + fontWeight = FontWeight.Bold, + accountViewModel = accountViewModel, + ) + } else { + Text( + text = stringRes(R.string.chat_zap_anonymous), + fontWeight = FontWeight.Bold, + ) + } - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - val sender = card.user - if (sender != null) { - UserPicture(sender, Size20dp, Modifier, accountViewModel, nav) Spacer(StdHorzSpacer) - UsernameDisplay( - baseUser = sender, - fontWeight = FontWeight.Bold, - accountViewModel = accountViewModel, - ) - } else { Text( - text = stringRes(R.string.chat_zap_anonymous), - fontWeight = FontWeight.Bold, + text = stringRes(R.string.chat_zap_amount_suffix, amountText), + color = BitcoinOrange, + fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, ) } - Spacer(StdHorzSpacer) - Text( - text = stringRes(R.string.chat_zap_amount_suffix, amountText), - color = BitcoinOrange, - fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal, - ) - } - - card.comment?.takeIf { it.isNotBlank() }?.let { comment -> - Spacer(Modifier.padding(top = 2.dp)) - CrossfadeToDisplayComment( - comment = comment, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel, - ) + card.comment?.takeIf { it.isNotBlank() }?.let { comment -> + Spacer(Modifier.padding(top = 2.dp)) + CrossfadeToDisplayComment( + comment = comment, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt new file mode 100644 index 000000000..ae6d80efb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Shared rounded, accent-tinted container used by the centered "system-style" + * cards rendered inside the live stream chat feed (zaps, raids, clips). + * + * Keeps the three renderers visually consistent so they clearly stand apart + * from regular chat bubbles. The content slot uses a BoxScope so callers can + * pick their own internal layout (Row / Column / whatever). + */ +@Composable +fun StreamSystemCard( + accent: Color = MaterialTheme.colorScheme.primary, + accentAlpha: Float = 0.12f, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit, +) { + val base = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp) + .clip(RoundedCornerShape(8.dp)) + .background(accent.copy(alpha = accentAlpha)) + + val clickable = if (onClick != null) base.clickable(onClick = onClick) else base + + Box( + modifier = clickable.padding(horizontal = 10.dp, vertical = 8.dp), + content = content, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt index 1437b5312..15fdec655 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.ZapIcon import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size24dp @@ -63,9 +65,13 @@ import java.math.BigDecimal private const val TOP_ZAPPERS_LIMIT = 10 +/** Sentinel key used to bucket every anonymous/private zap into a single leaderboard entry. */ +private const val ANON_KEY = "anon" + private data class TopZapperEntry( val zapperPubKey: HexKey, val totalSats: BigDecimal, + val isAnonymous: Boolean, ) /** @@ -137,26 +143,37 @@ private fun ZapperPill( accountViewModel: AccountViewModel, nav: INav, ) { + val clickModifier = + if (entry.isAnonymous) { + Modifier + } else { + Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) } + } + Surface( shape = CircleShape, color = MaterialTheme.colorScheme.surface, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = - Modifier.clickable { - nav.nav(Route.Profile(entry.zapperPubKey)) - }, + modifier = clickModifier, ) { Row( modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - UserPicture( - userHex = entry.zapperPubKey, - size = Size24dp, - accountViewModel = accountViewModel, - nav = nav, - ) + if (entry.isAnonymous) { + Text( + text = stringRes(R.string.chat_zap_anonymous), + style = MaterialTheme.typography.labelMedium, + ) + } else { + UserPicture( + userHex = entry.zapperPubKey, + size = Size24dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } ZapIcon(Size16Modifier, BitcoinOrange) Text( text = showAmountInteger(entry.totalSats), @@ -171,25 +188,25 @@ private fun aggregateTopZappers( channel: LiveActivitiesChannel, goalNote: Note?, ): List { - // receiptId -> (zapperPubKey, sats) — dedupes a zap that appears via both #a and #e. + // receiptId -> (zapperBucketKey, sats) — dedupes a zap that appears via both #a and #e. val byReceipt = HashMap>() // Stream-level zaps routed to the channel cache. channel.notes.forEach { _, note -> val ev = note.event if (ev is LnZapEvent) { - val zapper = ev.zapRequest?.pubKey ?: return@forEach + val request = ev.zapRequest ?: return@forEach val sats = ev.amount() ?: return@forEach - byReceipt[note.idHex] = zapper to sats + byReceipt[note.idHex] = bucketKeyFor(request) to sats } } // Goal-scoped zaps attached to the goal note via #e. goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach - val zapper = (zapRequestNote.event as? LnZapRequestEvent)?.pubKey ?: return@forEach + val request = zapRequestNote.event as? LnZapRequestEvent ?: return@forEach val sats = receiptEv.amount() ?: return@forEach - byReceipt[receiptNote.idHex] = zapper to sats + byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats } if (byReceipt.isEmpty()) return emptyList() @@ -203,6 +220,9 @@ private fun aggregateTopZappers( .asSequence() .sortedByDescending { it.value } .take(TOP_ZAPPERS_LIMIT) - .map { TopZapperEntry(it.key, it.value) } + .map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY) } .toList() } + +/** Returns the aggregation key for a zap request: real pubkey, or the anon sentinel for any `anon`-tagged zap. */ +private fun bucketKeyFor(request: LnZapRequestEvent): HexKey = if (request.tags.any { it.isNotEmpty() && it[0] == "anon" }) ANON_KEY else request.pubKey diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt new file mode 100644 index 000000000..473d8374e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/clip/LiveActivitiesClipEventTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.clip + +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class LiveActivitiesClipEventTest { + private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + private val dummySig = "0".repeat(128) + + @Test + fun parsesZapStreamShapedClip() { + val event = + LiveActivitiesClipEvent( + id = "2".repeat(64), + pubKey = viewerAuthor, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"), + arrayOf("p", host), + arrayOf("r", "https://cdn.example/clip.mp4"), + arrayOf("title", "Nice moment"), + arrayOf("alt", "Live stream clip"), + ), + content = "Check this out", + sig = dummySig, + ) + + val activity = assertNotNull(event.activity()) + assertEquals(LiveActivitiesEvent.KIND, activity.kind) + assertEquals(host, activity.pubKeyHex) + assertEquals("stream-d", activity.dTag) + + assertEquals(host, event.host()) + assertEquals("https://cdn.example/clip.mp4", event.videoUrl()) + assertEquals("Nice moment", event.title()) + assertEquals("Check this out", event.content) + } + + @Test + fun ignoresClipLackingStreamReference() { + val event = + LiveActivitiesClipEvent( + id = "2".repeat(64), + pubKey = viewerAuthor, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("p", host), + arrayOf("r", "https://cdn.example/clip.mp4"), + arrayOf("title", "Nice moment"), + ), + content = "", + sig = dummySig, + ) + + assertNull(event.activity()) + assertNull(event.activityAddress()) + assertEquals("https://cdn.example/clip.mp4", event.videoUrl()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt new file mode 100644 index 000000000..25b02addb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/raid/LiveActivitiesRaidEventTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.raid + +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class LiveActivitiesRaidEventTest { + private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + private val dummySig = "0".repeat(128) + + @Test + fun parsesRootAndMentionAddresses() { + val event = + LiveActivitiesRaidEvent( + id = "1".repeat(64), + pubKey = author, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"), + arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"), + ), + content = "Heading over to stream!", + sig = dummySig, + ) + + val from = assertNotNull(event.fromAddress()) + assertEquals(LiveActivitiesEvent.KIND, from.kind) + assertEquals(sourceHost, from.pubKeyHex) + assertEquals("source-d", from.dTag) + + val to = assertNotNull(event.toAddress()) + assertEquals(LiveActivitiesEvent.KIND, to.kind) + assertEquals(targetHost, to.pubKeyHex) + assertEquals("target-d", to.dTag) + } + + @Test + fun ignoresUnmarkedOrWrongKindATags() { + val event = + LiveActivitiesRaidEvent( + id = "1".repeat(64), + pubKey = author, + createdAt = 1_700_000_000L, + tags = + arrayOf( + // Wrong marker + arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"), + // Wrong kind + arrayOf("a", "30023:$sourceHost:article", "", "root"), + // No marker at all + arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"), + ), + content = "", + sig = dummySig, + ) + + assertNull(event.fromAddress()) + assertNull(event.toAddress()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt new file mode 100644 index 000000000..20ce31fa6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/streaming/LiveActivitiesEventGoalTagTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities.streaming + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class LiveActivitiesEventGoalTagTest { + private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + private val dummySig = "0".repeat(128) + + @Test + fun readsZapStreamGoalTag() { + val event = + LiveActivitiesEvent( + id = "3".repeat(64), + pubKey = host, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("d", "stream-d"), + arrayOf("title", "My stream"), + arrayOf("goal", goalId), + ), + content = "", + sig = dummySig, + ) + + assertEquals(goalId, event.goalEventId()) + } + + @Test + fun returnsNullWhenNoGoalTag() { + val event = + LiveActivitiesEvent( + id = "3".repeat(64), + pubKey = host, + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("d", "stream-d")), + content = "", + sig = dummySig, + ) + + assertNull(event.goalEventId()) + } + + @Test + fun returnsNullWhenGoalTagEmpty() { + val event = + LiveActivitiesEvent( + id = "3".repeat(64), + pubKey = host, + createdAt = 1_700_000_000L, + tags = + arrayOf( + arrayOf("d", "stream-d"), + arrayOf("goal", ""), + ), + content = "", + sig = dummySig, + ) + + assertNull(event.goalEventId()) + } +} From e12b6b4172b0bd5476746940f47ebdfeb7b87d9e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:33:55 +0000 Subject: [PATCH 25/34] feat(live-chat): surface stream clips in the profile gallery tab Clips are authored by viewers but carry a `p` tag identifying the stream host, so they naturally belong on the host's profile. Extend the profile media subscription with `{kinds:[1313], #p:[pubkey]}`, accept LiveActivitiesClipEvent in UserProfileGalleryFeedFilter when the clip references a stream hosted by this user, and render the clip's `r`-tag URL as a MediaUrlVideo tile in GalleryThumbnail. Also plug LiveActivitiesClipEvent into NoteCompose via the existing RenderChatClip so tapping a clip tile opens a working thread view instead of an empty one. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/ui/note/NoteCompose.kt | 6 ++++ .../datasource/FilterUserProfileMedia.kt | 35 +++++++++++++------ .../loggedIn/profile/gallery/GalleryThumb.kt | 16 +++++++++ .../dal/UserProfileGalleryFeedFilter.kt | 29 +++++++++------ 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a869d042b..8f007e18f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -172,6 +172,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -250,6 +251,7 @@ import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent @@ -938,6 +940,10 @@ private fun RenderNoteRow( RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } + is LiveActivitiesClipEvent -> { + RenderChatClip(baseNote, accountViewModel, nav) + } + is FhirResourceEvent -> { RenderFhirResource(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index 93170f9d3..ae247c5dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -51,16 +52,30 @@ fun filterUserProfileMedia( ?: user.allUsedRelaysOrNull() ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) - return relays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = UserProfileMediaKinds, - authors = listOf(user.pubkeyHex), - limit = 200, - since = since?.get(relay)?.time, - ), + return relays.flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = UserProfileMediaKinds, + authors = listOf(user.pubkeyHex), + limit = 200, + since = since?.get(relay)?.time, + ), + ), + // Clips are authored by viewers but carry a `p` tag identifying the stream host. + // Fetch clips OF this profile's streams so they surface in their gallery. + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LiveActivitiesClipEvent.KIND), + tags = mapOf("p" to listOf(user.pubkeyHex)), + limit = 100, + since = since?.get(relay)?.time, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 66f06d75e..69f439936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent @@ -130,6 +131,21 @@ fun GalleryThumbnail( thumbhash = imeta.thumbhash, ) } + } else if (noteEvent is LiveActivitiesClipEvent) { + noteEvent.videoUrl()?.let { url -> + listOf( + MediaUrlVideo( + url = url, + description = noteEvent.title() ?: noteEvent.content, + hash = null, + blurhash = null, + dim = null, + uri = null, + mimeType = null, + thumbhash = null, + ), + ) + } ?: emptyList() } else { emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 0e943641d..bcb315079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent @@ -75,17 +76,23 @@ class UserProfileGalleryFeedFilter( user: User, ): Boolean { val noteEvent = it.event - return ( - ( - it.event?.pubKey == user.pubkeyHex && - ( - noteEvent is PictureEvent || - noteEvent is RegularVideoEvent || - (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || - (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) - ) - ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) - ) && + val authoredByUser = + it.event?.pubKey == user.pubkeyHex && + ( + noteEvent is PictureEvent || + noteEvent is RegularVideoEvent || + (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || + (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) + ) + + // Clips are authored by viewers, not the host — accept them when they reference + // a stream hosted by this user AND carry a playable URL. + val clipOfUsersStream = + noteEvent is LiveActivitiesClipEvent && + noteEvent.host() == user.pubkeyHex && + !noteEvent.videoUrl().isNullOrBlank() + + return (authoredByUser || clipOfUsersStream) && params.match(noteEvent, it.relays) && account.isAcceptable(it) } From 4888d30f6cf08a7ed7f95941154fee017742ebce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 00:20:01 +0000 Subject: [PATCH 26/34] refactor(live-chat): lift NIP-53 live-stream state into commons Address the architectural critique from self-review. The leaderboard math and the shared system card are now platform-neutral primitives in commons, the on-UI-thread aggregation is gone, and two latent correctness bugs around subscription lifecycle and zap routing are fixed. - Introduce a pure LiveActivityTopZappersAggregator in commons that takes plain ZapContribution values and returns a sorted, deduped, anon-bucketed top-N list. Covered by a unit-test suite that is Compose/Android-independent. - Introduce LiveStreamTopZappersViewModel in commons/commonMain. It owns two partitioned contribution maps (stream-scoped and goal-scoped), mutates them under a Mutex on the IO dispatcher in response to channel.changesFlow and Note.zaps.stateFlow emissions, and publishes the aggregator result via a StateFlow>. UI is now a dumb consumer with no aggregation logic left in the composable. - Move StreamSystemCard to commons/commonMain/compose/ so Desktop can consume it alongside Android. - Fix attachZapToLiveActivityChannel to run even when a zap receipt was already consumed by another subscription. Previously a zap first seen by notifications/profile would never reach the stream's channel cache; addNote is already idempotent so this is a safe retro-route. - Fix ChannelFilterAssemblerSubscription to re-invalidate filters when a live-activity channel's metadata arrives. The 30311 stream event usually lands after the initial assembler run, so the goal-tag-driven subscription never fired. Now it re-runs as metadata resolves and the goal id is discovered. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../amethyst/model/LocalCache.kt | 10 +- .../chats/feed/types/RenderChatClip.kt | 1 + .../chats/feed/types/RenderChatRaid.kt | 1 + .../chats/feed/types/RenderChatZap.kt | 1 + .../ChannelFilterAssemblerSubscription.kt | 18 ++ .../header/LiveStreamTopZappers.kt | 131 ++++--------- .../nip53LiveActivities}/StreamSystemCard.kt | 6 +- .../LiveActivityTopZappersAggregator.kt | 106 +++++++++++ .../LiveStreamTopZappersViewModel.kt | 173 ++++++++++++++++++ .../LiveActivityTopZappersAggregatorTest.kt | 153 ++++++++++++++++ 10 files changed, 497 insertions(+), 103 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities}/StreamSystemCard.kt (89%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c58e725ef..ac8d998f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1555,8 +1555,14 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val note = getOrCreateNote(event.id) - // Already processed this event. - if (note.event != null) return false + + // Already processed this event — still ensure it's routed into any live-activity + // channel(s) it references. A zap that was first consumed by, e.g., the notifications + // subscription must still appear in the stream's chat when the user opens the stream. + if (note.event != null) { + attachZapToLiveActivityChannel(event, note, relay) + return false + } if (wasVerified || justVerify(event)) { val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt index 4561d7025..bb5a8f39e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatClip.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt index 3bc3d1bf3..5838ff092 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatRaid.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt index b066890b8..967ca1ec5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderChatZap.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities.StreamSystemCard import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt index ea531c1bd..e8231e71b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/ChannelFilterAssemblerSubscription.kt @@ -21,8 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -40,4 +44,18 @@ fun ChannelFilterAssemblerSubscription( } KeyDataSourceSubscription(state, dataSource) + + // Live streams need the 30311 event to populate `channel.info` before we can read its + // `goal` tag and add the goal+zap subscriptions. Re-invalidate when metadata changes so + // the goal filter fires as soon as the stream event arrives. + if (channel is LiveActivitiesChannel) { + val metadataState by channel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val goalId = channel.info?.goalEventId() + LaunchedEffect(goalId, metadataState) { + dataSource.invalidateFilters() + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt index 15fdec655..1624e3f51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -43,11 +43,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry +import com.vitorpamplona.amethyst.commons.viewmodels.LiveStreamTopZappersViewModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -58,27 +61,14 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size24dp -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent -import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import java.math.BigDecimal -private const val TOP_ZAPPERS_LIMIT = 10 - -/** Sentinel key used to bucket every anonymous/private zap into a single leaderboard entry. */ -private const val ANON_KEY = "anon" - -private data class TopZapperEntry( - val zapperPubKey: HexKey, - val totalSats: BigDecimal, - val isAnonymous: Boolean, -) - /** - * Horizontally-scrollable leaderboard of the top zappers on a live stream. - * Aggregates zaps from both the stream's #a subscription and the attached - * NIP-75 zap goal (if any), de-duplicating by zap-receipt id. Matches - * zap.stream's TopZappers UX: pill chips with avatar + lightning + sats. + * Horizontally-scrollable leaderboard of the top zappers on a live stream. Matches + * zap.stream's TopZappers look: pill chips with avatar + lightning + sats. + * + * State is owned by [LiveStreamTopZappersViewModel], which maintains the aggregation + * incrementally off the UI thread and publishes a stable `List`. */ @Composable fun LiveStreamTopZappers( @@ -86,43 +76,33 @@ fun LiveStreamTopZappers( accountViewModel: AccountViewModel, nav: INav, ) { + val topZappersVm: LiveStreamTopZappersViewModel = + viewModel( + key = "TopZappers-${channel.address.toValue()}", + factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = LiveStreamTopZappersViewModel(channel) as T + }, + ) + + // Track the goal note lifecycle — pass it to the VM as it resolves, clear on channel change. val goalId = channel.info?.goalEventId() - if (goalId != null) { - var goalNote by remember(goalId) { mutableStateOf(accountViewModel.getNoteIfExists(goalId)) } - if (goalNote == null) { - LaunchedEffect(goalId) { - goalNote = accountViewModel.checkGetOrCreateNote(goalId) + var goalNoteHolder by remember(goalId) { + mutableStateOf(accountViewModel.getNoteIfExists(goalId)) + } + LaunchedEffect(goalId) { + if (goalNoteHolder == null) { + goalNoteHolder = accountViewModel.checkGetOrCreateNote(goalId) } + topZappersVm.setGoalNote(goalNoteHolder) } - TopZappersStrip(channel, goalNote, accountViewModel, nav) } else { - TopZappersStrip(channel, null, accountViewModel, nav) + LaunchedEffect(channel) { topZappersVm.setGoalNote(null) } } -} -@Composable -private fun TopZappersStrip( - channel: LiveActivitiesChannel, - goalNote: Note?, - accountViewModel: AccountViewModel, - nav: INav, -) { - // Trigger recomposition whenever a new zap lands in the channel cache. - val channelTick by channel.changesFlow().collectAsStateWithLifecycle(initialValue = null) - - // Trigger recomposition when zaps arrive for the goal note. - val goalZapsState = - if (goalNote != null) { - observeNoteZaps(goalNote, accountViewModel).value - } else { - null - } - - val entries = - remember(channel, channelTick, goalNote, goalZapsState) { - aggregateTopZappers(channel, goalNote) - } + val entries by topZappersVm.topZappers.collectAsStateWithLifecycle() if (entries.isEmpty()) return @@ -131,7 +111,7 @@ private fun TopZappersStrip( horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = 4.dp), ) { - items(entries, key = { it.zapperPubKey }) { entry -> + items(entries, key = { it.bucketKey }) { entry -> ZapperPill(entry, accountViewModel, nav) } } @@ -147,7 +127,7 @@ private fun ZapperPill( if (entry.isAnonymous) { Modifier } else { - Modifier.clickable { nav.nav(Route.Profile(entry.zapperPubKey)) } + Modifier.clickable { nav.nav(Route.Profile(entry.bucketKey)) } } Surface( @@ -168,7 +148,7 @@ private fun ZapperPill( ) } else { UserPicture( - userHex = entry.zapperPubKey, + userHex = entry.bucketKey, size = Size24dp, accountViewModel = accountViewModel, nav = nav, @@ -176,53 +156,10 @@ private fun ZapperPill( } ZapIcon(Size16Modifier, BitcoinOrange) Text( - text = showAmountInteger(entry.totalSats), + text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), style = MaterialTheme.typography.labelMedium, ) Spacer(Modifier.padding(start = 2.dp)) } } } - -private fun aggregateTopZappers( - channel: LiveActivitiesChannel, - goalNote: Note?, -): List { - // receiptId -> (zapperBucketKey, sats) — dedupes a zap that appears via both #a and #e. - val byReceipt = HashMap>() - - // Stream-level zaps routed to the channel cache. - channel.notes.forEach { _, note -> - val ev = note.event - if (ev is LnZapEvent) { - val request = ev.zapRequest ?: return@forEach - val sats = ev.amount() ?: return@forEach - byReceipt[note.idHex] = bucketKeyFor(request) to sats - } - } - - // Goal-scoped zaps attached to the goal note via #e. - goalNote?.zaps?.forEach { (zapRequestNote, receiptNote) -> - val receiptEv = receiptNote?.event as? LnZapEvent ?: return@forEach - val request = zapRequestNote.event as? LnZapRequestEvent ?: return@forEach - val sats = receiptEv.amount() ?: return@forEach - byReceipt[receiptNote.idHex] = bucketKeyFor(request) to sats - } - - if (byReceipt.isEmpty()) return emptyList() - - val totals = HashMap(byReceipt.size) - byReceipt.values.forEach { (pk, sats) -> - totals[pk] = (totals[pk] ?: BigDecimal.ZERO) + sats - } - - return totals.entries - .asSequence() - .sortedByDescending { it.value } - .take(TOP_ZAPPERS_LIMIT) - .map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY) } - .toList() -} - -/** Returns the aggregation key for a zap request: real pubkey, or the anon sentinel for any `anon`-tagged zap. */ -private fun bucketKeyFor(request: LnZapRequestEvent): HexKey = if (request.tags.any { it.isNotEmpty() && it[0] == "anon" }) ANON_KEY else request.pubKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt index ae6d80efb..52355902d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/StreamSystemCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/nip53LiveActivities/StreamSystemCard.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types +package com.vitorpamplona.amethyst.commons.compose.nip53LiveActivities import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -38,9 +38,7 @@ import androidx.compose.ui.unit.dp * Shared rounded, accent-tinted container used by the centered "system-style" * cards rendered inside the live stream chat feed (zaps, raids, clips). * - * Keeps the three renderers visually consistent so they clearly stand apart - * from regular chat bubbles. The content slot uses a BoxScope so callers can - * pick their own internal layout (Row / Column / whatever). + * Kept platform-neutral so Desktop can consume it alongside Android. */ @Composable fun StreamSystemCard( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt new file mode 100644 index 000000000..2d0e88564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregator.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip53LiveActivities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A single entry on a stream's top-zappers leaderboard. + * + * @param bucketKey either a real zapper pubkey or [LiveActivityTopZappersAggregator.ANON_KEY] + * for zaps whose zap-request carried an `anon` tag. + * @param totalSats sum of amounts across every zap that mapped to this bucket. + * @param isAnonymous true when the bucket is the shared anonymous pool. + */ +@Immutable +data class TopZapperEntry( + val bucketKey: HexKey, + val totalSats: Long, + val isAnonymous: Boolean, +) + +/** + * Represents a single zap-receipt contribution before aggregation. All inputs are + * plain values so this stays testable in pure Kotlin without Compose or Android. + * + * @param receiptId the kind-9735 receipt event id — used to de-duplicate receipts that + * appear in both the stream's #a and the goal's #e feed. + * @param zapperPubKey pubkey of the zap-request signer (random one-time key for anon/private). + * @param isAnonymous true when the zap-request carried an `anon` tag (empty or encrypted). + * @param sats amount in satoshis. + */ +@Immutable +data class ZapContribution( + val receiptId: HexKey, + val zapperPubKey: HexKey, + val isAnonymous: Boolean, + val sats: Long, +) + +/** + * Computes a top-N zap leaderboard for a live stream. + * + * Inputs are raw [ZapContribution]s from any number of sources; the aggregator handles: + * - de-duplication by receipt id (a zap that tags both `#a` and `#e` counts once), + * - anonymous bucketing (all `anon`-tagged zaps collapse into one entry), + * - sum-by-contributor and sort-desc-by-total, + * - top-N truncation. + * + * This is a pure function — no Compose, no Android, no LocalCache — so it can be unit + * tested in isolation and reused from any platform target. + */ +object LiveActivityTopZappersAggregator { + /** Sentinel key that collapses every anonymous / private zap into a single leaderboard entry. */ + const val ANON_KEY: HexKey = "anon" + + /** Default cap — matches zap.stream's TopZappers limit. */ + const val DEFAULT_LIMIT = 10 + + fun aggregate( + contributions: Iterable, + limit: Int = DEFAULT_LIMIT, + ): List { + if (limit <= 0) return emptyList() + + // receiptId -> contribution. Dedupes a single zap that arrives via both #a and #e. + val unique = HashMap() + for (c in contributions) { + unique[c.receiptId] = c + } + if (unique.isEmpty()) return emptyList() + + val totals = HashMap(unique.size) + var hasAnon = false + for (c in unique.values) { + val key = if (c.isAnonymous) ANON_KEY else c.zapperPubKey + if (c.isAnonymous) hasAnon = true + totals[key] = (totals[key] ?: 0L) + c.sats + } + + return totals.entries + .asSequence() + .sortedByDescending { it.value } + .take(limit) + .map { TopZapperEntry(it.key, it.value, isAnonymous = it.key == ANON_KEY && hasAnon) } + .toList() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt new file mode 100644 index 000000000..032c53a75 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/LiveStreamTopZappersViewModel.kt @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.ListChange +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Live-stream top-zappers leaderboard state, computed off the UI thread and maintained + * incrementally. + * + * The leaderboard draws from two independent contribution sets keyed by zap-receipt id: + * - [streamContributions] — zaps routed into the [LiveActivitiesChannel]'s cache via the + * stream's `#a` subscription; + * - [goalContributions] — zaps attached to a NIP-75 goal note via `#e`. + * + * The union is flattened on each change and fed into [LiveActivityTopZappersAggregator] for + * de-dup, anonymous bucketing, sum-by-contributor, and top-N sort, publishing a stable + * `List` via [topZappers] for dumb UI consumption. + */ +@Stable +class LiveStreamTopZappersViewModel( + private val channel: LiveActivitiesChannel, + private val limit: Int = LiveActivityTopZappersAggregator.DEFAULT_LIMIT, +) : ViewModel() { + private val mutex = Mutex() + + // receiptId -> ZapContribution, partitioned by source. Aggregator dedupes at merge. + private val streamContributions = HashMap() + private val goalContributions = HashMap() + + private val _topZappers = MutableStateFlow>(emptyList()) + val topZappers: StateFlow> = _topZappers.asStateFlow() + + private var goalNote: Note? = null + private var goalObserverJob: Job? = null + + init { + viewModelScope.launch(Dispatchers.IO) { + // Seed from the channel cache. + mutex.withLock { + channel.notes.forEach { _, note -> + contributionFromStreamZap(note)?.let { streamContributions[it.receiptId] = it } + } + } + publish() + + // Keep in sync with subsequent arrivals. + channel.changesFlow().collect { change -> + val mutated = applyChannelChange(change) + if (mutated) publish() + } + } + } + + /** + * Attach (or detach) a NIP-75 goal note whose zaps should flow into the leaderboard. + * Passing the same instance twice is a no-op; passing null clears the goal source. + */ + fun setGoalNote(newGoalNote: Note?) { + if (newGoalNote === goalNote) return + goalNote = newGoalNote + + goalObserverJob?.cancel() + goalObserverJob = + viewModelScope.launch(Dispatchers.IO) { + // Reset goal bucket and re-seed from the new note (if any) before subscribing. + rebuildGoalContributions(newGoalNote) + publish() + + if (newGoalNote == null) return@launch + + // Observe future goal-zap arrivals. Each emission rebuilds the goal bucket; + // the aggregator already handles de-dup at merge-time. + newGoalNote.flow().zaps.stateFlow.collect { + rebuildGoalContributions(newGoalNote) + publish() + } + } + } + + private suspend fun applyChannelChange(change: ListChange): Boolean = + mutex.withLock { + when (change) { + is ListChange.Addition -> upsertStream(change.item) + is ListChange.Deletion -> removeStream(change.item.idHex) + is ListChange.SetAddition -> change.item.fold(false) { acc, n -> upsertStream(n) || acc } + is ListChange.SetDeletion -> change.item.fold(false) { acc, n -> removeStream(n.idHex) || acc } + } + } + + private fun upsertStream(note: Note): Boolean { + val c = contributionFromStreamZap(note) ?: return false + val prev = streamContributions[c.receiptId] + if (prev == c) return false + streamContributions[c.receiptId] = c + return true + } + + private fun removeStream(receiptId: HexKey): Boolean = streamContributions.remove(receiptId) != null + + private suspend fun rebuildGoalContributions(goal: Note?) { + mutex.withLock { + goalContributions.clear() + goal?.zaps?.forEach { (zapRequestNote, receiptNote) -> + contributionFromGoalZap(zapRequestNote, receiptNote)?.let { + goalContributions[it.receiptId] = it + } + } + } + } + + private fun publish() { + val merged = streamContributions.values + goalContributions.values + _topZappers.value = LiveActivityTopZappersAggregator.aggregate(merged, limit) + } + + private fun contributionFromStreamZap(note: Note): ZapContribution? { + val ev = note.event as? LnZapEvent ?: return null + val request = ev.zapRequest ?: return null + val sats = ev.amount()?.toLong() ?: return null + return ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) + } + + private fun contributionFromGoalZap( + zapRequestNote: Note, + receiptNote: Note?, + ): ZapContribution? { + val receiptEv = receiptNote?.event as? LnZapEvent ?: return null + val request = zapRequestNote.event as? LnZapRequestEvent ?: return null + val sats = receiptEv.amount()?.toLong() ?: return null + return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats) + } +} + +/** True for both public anonymous and NIP-57 private zaps (any `anon` tag, empty or encrypted). */ +private fun LnZapRequestEvent.isAnonTagged(): Boolean = tags.any { it.isNotEmpty() && it[0] == "anon" } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt new file mode 100644 index 000000000..f1d64c7e1 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/LiveActivityTopZappersAggregatorTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip53LiveActivities + +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.ANON_KEY +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator.aggregate +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LiveActivityTopZappersAggregatorTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val carol = "c".repeat(64) + + private fun zap( + receipt: String, + zapper: String = alice, + sats: Long = 100, + anon: Boolean = false, + ) = ZapContribution( + receiptId = receipt, + zapperPubKey = zapper, + isAnonymous = anon, + sats = sats, + ) + + @Test + fun sortsByTotalDescending() { + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", bob, 500), + zap("r3", carol, 250), + ), + ) + + assertEquals(listOf(bob, carol, alice), result.map { it.bucketKey }) + assertEquals(listOf(500L, 250L, 100L), result.map { it.totalSats }) + } + + @Test + fun sumsMultipleZapsFromSameContributor() { + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", alice, 200), + zap("r3", bob, 250), + ), + ) + + assertEquals(listOf(alice, bob), result.map { it.bucketKey }) + assertEquals(300L, result[0].totalSats) + assertEquals(250L, result[1].totalSats) + } + + @Test + fun dedupesByReceiptId() { + // Same zap arriving via both #a (stream) and #e (goal) should count once. + val result = + aggregate( + listOf( + zap("r1", alice, 500), + zap("r1", alice, 500), + ), + ) + + assertEquals(1, result.size) + assertEquals(500L, result[0].totalSats) + } + + @Test + fun bucketsAnonymousZapsUnderSingleSentinel() { + // Each anon zap has a random one-time pubkey; they must collapse into one entry. + val anon1 = "1".repeat(64) + val anon2 = "2".repeat(64) + val result = + aggregate( + listOf( + zap("r1", alice, 100), + zap("r2", anon1, 200, anon = true), + zap("r3", anon2, 300, anon = true), + ), + ) + + assertEquals(2, result.size) + val anon = result.first { it.isAnonymous } + assertEquals(ANON_KEY, anon.bucketKey) + assertEquals(500L, anon.totalSats) + val realUser = result.first { !it.isAnonymous } + assertEquals(alice, realUser.bucketKey) + assertEquals(100L, realUser.totalSats) + } + + @Test + fun doesNotFalselyMarkRealPubkeyAnonymousWhenItCollidesWithSentinel() { + // Defensive: if some stream has a zapper whose pubkey literally equals "anon", + // we don't collide because the sentinel is 4 chars and real pubkeys are 64. + val result = + aggregate( + listOf( + zap("r1", alice, 100), + ), + ) + + assertTrue(result.none { it.isAnonymous }) + } + + @Test + fun respectsTopNLimit() { + val contributions = + (1..15).map { + zap(receipt = "r$it", zapper = "$it".repeat(64), sats = it.toLong() * 10) + } + + val result = aggregate(contributions, limit = 5) + + assertEquals(5, result.size) + // Highest total first. + assertEquals(15 * 10L, result[0].totalSats) + assertEquals(11 * 10L, result[4].totalSats) + } + + @Test + fun handlesEmptyInput() { + assertTrue(aggregate(emptyList()).isEmpty()) + } + + @Test + fun handlesZeroLimit() { + assertTrue(aggregate(listOf(zap("r1")), limit = 0).isEmpty()) + } +} From 5705a2b84075d901ef66eb913fb31ab53653c2b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:38:31 +0000 Subject: [PATCH 27/34] fix(marmot): outer-encrypt commits with pre-commit exporter secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 9420 §12.4 and the MDK reference implementation, a kind:445 Commit MUST be outer-encrypted with the epoch-N exporter secret — the key existing members still hold — so that they can decrypt, process the commit, and advance to N+1. Amethyst was instead encrypting with the epoch-(N+1) key because MlsGroupManager.addMember / removeMember / updateGroupExtensions applied the commit locally *before* handing the bytes to MarmotOutboundProcessor.buildCommitEvent, which read the current (post-commit) exporter via groupManager.exporterSecret. Observed in a 3-party Amethyst-to-Amethyst test (David creates group, adds Eden then Fred, sends "Hi"): Eden joined at epoch 1 via Welcome, then "succeeded" in outer-decrypting the add-Eden kind:445 with her own post-commit key but failed to apply ("Duplicate encryption key: leaf 1") because she was already in the tree from the Welcome. Eden never advanced past epoch 1, so Fred's subsequent join and David's application message were undecryptable. Fred, joining directly at epoch 2, saw the Hi message fine — matching the symptom where the last-added member is the only one who can read new messages. Fix splits commit creation from commit application on the outbound path, mirroring OpenMLS / MDK's `create_commit` + `merge_pending_commit` pair: - MlsGroup gains stageCommit() / mergeStagedCommit(). stageCommit captures a full snapshot, runs the existing commit logic (which mutates in place), captures the post-commit snapshot, then restores the pre-commit snapshot so the caller observes epoch N until merge. The returned StagedCommit carries the pre-commit exporter secret and the framed commit bytes. stageAddMember / stageRemoveMember are thin wrappers that propose + stage. - MlsGroupManager exposes stageAddMember, stageRemoveMember, stageRotateSigningKey, stageUpdateGroupExtensions, and mergeStagedCommit. The eager addMember/removeMember/commit/etc. entry points are retained (documented as test-only) so the MLS unit tests in MlsGroupTest, MlsGroupLifecycleTest, etc. keep working unchanged. - MarmotOutboundProcessor.buildCommitEvent takes an optional exporterKey override; callers pass StagedCommit.preCommitExporterSecret. - MarmotManager.addMember / removeMember / updateGroupMetadata now stage → build kind:445 → markEventProcessed → mergeStagedCommit. - MlsGroupManager.processCommit was also retaining epoch secrets *before* calling group.processCommit, so a failed commit (such as the add-me relay echo that the new member can't apply) polluted the retained window with a duplicate of the current epoch key. Now retain only on success. Regression tests: - testStagedAddMemberUsesPreCommitExporter: asserts stage does not advance the epoch and that preCommitExporterSecret equals the pre-stage exporter output. - testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage: end-to-end David/Eden/Fred reproduction — creator adds two members in sequence and publishes an application message; both members must decrypt it. Fails without the fix, passes with it. https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL --- .../amethyst/commons/marmot/MarmotManager.kt | 51 ++++- .../quartz/marmot/MarmotOutboundProcessor.kt | 19 +- .../quartz/marmot/mls/group/MlsGroup.kt | 153 ++++++++++++++- .../marmot/mls/group/MlsGroupManager.kt | 148 ++++++++++++--- .../quartz/marmot/mls/group/StagedCommit.kt | 112 +++++++++++ .../quartz/marmot/MarmotPipelineTest.kt | 175 ++++++++++++++++++ 6 files changed, 613 insertions(+), 45 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt 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 b5b8c94de..635ae97ea 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 @@ -178,21 +178,39 @@ class MarmotManager( "KeyPackage credential identity does not match memberPubKey" } - val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes) - val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) - // We've already applied this commit locally; prevent the relay-echoed - // copy from being re-applied by the inbound pipeline. + // Stage-then-merge (MDK / RFC 9420 §12.4): build the kind:445 with the + // PRE-commit epoch's exporter secret so that other existing members + // can outer-decrypt and process it. Only advance our own epoch once + // the framed commit has been handed to the outbound path. + val staged = groupManager.stageAddMember(nostrGroupId, keyPackageBytes) + val commitEvent = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = staged.framedCommitBytes, + exporterKey = staged.preCommitExporterSecret, + ) + // The published kind:445 will echo back from the relay — without this + // dedup our own inbound pipeline would try to re-apply a commit whose + // epoch we've already merged below. inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) + val welcomeBytes = + staged.welcomeBytes + ?: throw IllegalStateException( + "stageAddMember did not produce a Welcome for $memberPubKey", + ) val welcomeDelivery = - welcomeSender.wrapWelcome( - commitResult = commitResult, + welcomeSender.wrapWelcomeBytes( + welcomeBytes = welcomeBytes, recipientPubKey = memberPubKey, keyPackageEventId = keyPackageEventId, relays = relays, nostrGroupId = nostrGroupId, ) + // Advance local epoch now that the commit + welcome have been built. + groupManager.mergeStagedCommit(nostrGroupId, staged) + return Pair(commitEvent, welcomeDelivery) } @@ -268,9 +286,15 @@ class MarmotManager( nostrGroupId: HexKey, targetLeafIndex: Int, ): OutboundGroupEvent { - val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex) - val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) + val staged = groupManager.stageRemoveMember(nostrGroupId, targetLeafIndex) + val commitEvent = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = staged.framedCommitBytes, + exporterKey = staged.preCommitExporterSecret, + ) inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) + groupManager.mergeStagedCommit(nostrGroupId, staged) return commitEvent } @@ -282,9 +306,16 @@ class MarmotManager( nostrGroupId: HexKey, metadata: MarmotGroupData, ): OutboundGroupEvent { - val commitResult = groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) - val commitEvent = outboundProcessor.buildCommitEvent(nostrGroupId, commitResult.framedCommitBytes) + val staged = + groupManager.stageUpdateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) + val commitEvent = + outboundProcessor.buildCommitEvent( + nostrGroupId = nostrGroupId, + commitBytes = staged.framedCommitBytes, + exporterKey = staged.preCommitExporterSecret, + ) inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) + groupManager.mergeStagedCommit(nostrGroupId, staged) return commitEvent } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt index df3cecc28..3f0e276cb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotOutboundProcessor.kt @@ -121,20 +121,29 @@ class MarmotOutboundProcessor( /** * Build a GroupEvent carrying a Commit for publishing. * - * Used after MlsGroupManager.commit() or addMember()/removeMember(). - * The commit bytes are already MLS-formatted. + * Used after MlsGroupManager.stageAddMember()/stageRemoveMember()/etc. + * The commit bytes are already MLS-formatted (PublicMessage envelope). * * @param nostrGroupId the Nostr group ID - * @param commitBytes the raw MLS commit bytes from CommitResult + * @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes] + * @param exporterKey optional explicit outer-encryption key. Callers + * publishing a Commit MUST pass the **pre-commit** exporter secret + * (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret]) + * so that other existing members at epoch N can decrypt and process + * the commit. If null, falls back to the current epoch's exporter + * secret — which is only correct when the commit has *not* been + * applied locally yet (i.e. this call is made before + * [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]). * @return the signed GroupEvent ready for relay publishing */ suspend fun buildCommitEvent( nostrGroupId: HexKey, commitBytes: ByteArray, + exporterKey: ByteArray? = null, ): OutboundGroupEvent { // Outer ChaCha20-Poly1305 encryption of the MLS commit - val exporterKey = groupManager.exporterSecret(nostrGroupId) - val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey) + val outerKey = exporterKey ?: groupManager.exporterSecret(nostrGroupId) + val encryptedContent = GroupEventEncryption.encrypt(commitBytes, outerKey) // Build the GroupEvent template val template = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 74abe20fc..9bd7f44a5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -98,7 +98,7 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance */ class MlsGroup private constructor( private var groupContext: GroupContext, - private val tree: RatchetTree, + private var tree: RatchetTree, private var myLeafIndex: Int, private var epochSecrets: EpochSecrets, private var secretTree: SecretTree, @@ -199,6 +199,125 @@ class MlsGroup private constructor( var reInitPending: Proposal.ReInit? = null private set + // --- Stage/Merge Infrastructure (RFC 9420 §12.4 / MDK parity) --- + + /** + * Capture a complete snapshot of every mutable field so this group can be + * restored atomically. Used by [stageCommit] to rewind after computing a + * Commit so the outer code can encrypt the kind:445 with the pre-commit + * exporter secret before advancing locally. + */ + private fun captureSnapshot(): MlsGroupSnapshot { + val treeWriter = TlsWriter() + tree.encodeTls(treeWriter) + return MlsGroupSnapshot( + groupContext = groupContext, + treeBytes = treeWriter.toByteArray(), + myLeafIndex = myLeafIndex, + epochSecrets = epochSecrets, + secretTree = secretTree, + initSecret = initSecret, + signingPrivateKey = signingPrivateKey, + encryptionPrivateKey = encryptionPrivateKey, + interimTranscriptHash = interimTranscriptHash, + pskStore = pskStore.toMap(), + pendingProposals = pendingProposals.toList(), + sentKeys = sentKeys.toMap(), + pendingSigningKey = pendingSigningKey, + pendingEncryptionKey = pendingEncryptionKey, + reInitPending = reInitPending, + ) + } + + /** + * Overwrite every mutable field of this group from a prior snapshot. + * See [captureSnapshot] for the use case. + */ + private fun restoreSnapshot(s: MlsGroupSnapshot) { + groupContext = s.groupContext + tree = RatchetTree.decodeTls(TlsReader(s.treeBytes)) + myLeafIndex = s.myLeafIndex + epochSecrets = s.epochSecrets + // secretTree is keyed on encryption_secret + leafCount; rebuilding from + // the snapshot's encryption_secret avoids relying on SecretTree being + // a pure data class, which it currently is, but keeps us robust. + secretTree = SecretTree(s.epochSecrets.encryptionSecret, tree.leafCount) + initSecret = s.initSecret + signingPrivateKey = s.signingPrivateKey + encryptionPrivateKey = s.encryptionPrivateKey + interimTranscriptHash = s.interimTranscriptHash + pskStore.clear() + pskStore.putAll(s.pskStore) + pendingProposals.clear() + pendingProposals.addAll(s.pendingProposals) + sentKeys.clear() + sentKeys.putAll(s.sentKeys) + pendingSigningKey = s.pendingSigningKey + pendingEncryptionKey = s.pendingEncryptionKey + reInitPending = s.reInitPending + } + + /** + * Build a Commit from the currently pending proposals **without** advancing + * the local epoch. The caller can then outer-encrypt the kind:445 using + * [StagedCommit.preCommitExporterSecret] — which is still the current + * epoch's key — and call [mergeStagedCommit] to advance locally only after + * the kind:445 has been handed to the transport. + * + * This mirrors OpenMLS / MDK's `create_commit` + `merge_pending_commit` + * pair and is the only correct way to encrypt a Commit: existing members + * still at epoch N need the pre-commit exporter secret to decrypt the + * outer layer, while the new member joining via Welcome does not need to + * process this Commit at all. + * + * On success, the group state is unchanged. + * On failure, the group state is unchanged. + */ + fun stageCommit(): StagedCommit { + val preCommitSnapshot = captureSnapshot() + val preCommitExporter = exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val preCommitEpoch = groupContext.epoch + + val committed: CommitResult = + try { + commit() + } catch (e: Exception) { + // commit() may have mutated fields before throwing — always roll back. + restoreSnapshot(preCommitSnapshot) + throw e + } + + val postCommitSnapshot = captureSnapshot() + val postCommitEpoch = groupContext.epoch + + // Rewind to pre-commit state so the outer code sees epoch N until it + // explicitly calls mergeStagedCommit. + restoreSnapshot(preCommitSnapshot) + + return StagedCommit( + preCommitExporterSecret = preCommitExporter, + preCommitEpoch = preCommitEpoch, + postCommitEpoch = postCommitEpoch, + commitBytes = committed.commitBytes, + framedCommitBytes = committed.framedCommitBytes, + welcomeBytes = committed.welcomeBytes, + groupInfoBytes = committed.groupInfoBytes, + postState = postCommitSnapshot, + ) + } + + /** + * Apply a previously [stageCommit]ed transition, advancing this group from + * epoch N to epoch N+1. Call once the Commit has been successfully handed + * to the transport. + */ + fun mergeStagedCommit(staged: StagedCommit) { + check(groupContext.epoch == staged.preCommitEpoch) { + "mergeStagedCommit: group at epoch ${groupContext.epoch} but staged commit is for epoch ${staged.preCommitEpoch}" + } + restoreSnapshot(staged.postState) + } + /** * Register a pre-shared key for use in PSK proposals. */ @@ -2072,21 +2191,53 @@ class MlsGroup private constructor( /** * Add a member to the group by their KeyPackage. * Creates and applies a Commit with an Add proposal. + * + * This advances the local epoch eagerly; the returned [CommitResult] is + * encrypted on the wire with the **post-commit** exporter key, which is + * only correct for tests / offline MLS scenarios. Production callers MUST + * use [stageAddMember] + [mergeStagedCommit] so the kind:445 can be + * wrapped with the pre-commit exporter key, matching the MDK reference + * and allowing other existing members to actually decrypt and process the + * commit. */ fun addMember(keyPackageBytes: ByteArray): CommitResult { proposeAdd(keyPackageBytes) return commit() } + /** + * Stage an Add-member Commit without advancing the local epoch. + * Returned [StagedCommit] carries the pre-commit exporter secret that the + * outer kind:445 layer MUST be encrypted with (RFC 9420 §12.4 + MDK). + * Call [mergeStagedCommit] after the commit has been handed to the + * transport to advance locally. + */ + fun stageAddMember(keyPackageBytes: ByteArray): StagedCommit { + proposeAdd(keyPackageBytes) + return stageCommit() + } + /** * Remove a member from the group. * Creates and applies a Commit with a Remove proposal. + * + * See [addMember] for why production callers should prefer + * [stageRemoveMember] instead. */ fun removeMember(targetLeafIndex: Int): CommitResult { proposeRemove(targetLeafIndex) return commit() } + /** + * Stage a Remove-member Commit without advancing the local epoch. + * See [stageAddMember] for the rationale. + */ + fun stageRemoveMember(targetLeafIndex: Int): StagedCommit { + proposeRemove(targetLeafIndex) + return stageCommit() + } + /** * Remove self from the group. * 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 bdfec1984..bea9ca8ac 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 @@ -267,11 +267,9 @@ class MlsGroupManager( suspend fun commit(nostrGroupId: HexKey): CommitResult = mutex.withLock { val group = requireGroup(nostrGroupId) - - // Retain current epoch secrets before transition - retainEpochSecrets(nostrGroupId, group) - + val retainedBefore = group.retainedSecrets() val result = group.commit() + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) result } @@ -292,10 +290,16 @@ class MlsGroupManager( ) = mutex.withLock { val group = requireGroup(nostrGroupId) - // Retain current epoch secrets before transition - retainEpochSecrets(nostrGroupId, group) + // Capture the outgoing epoch's secrets BEFORE advancing, but only + // commit them to the retention window once processCommit succeeds — + // otherwise a failed commit (e.g. "Duplicate encryption key" on an + // add-me relay echo) would pollute the window with a duplicate of + // the current epoch key, wasting the finite retention slots. + val retainedBefore = group.retainedSecrets() group.processCommit(commitBytes, senderLeafIndex, confirmationTag) + + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) } @@ -355,10 +359,18 @@ class MlsGroupManager( null } - // --- Member Management --- + // --- Member Management (atomic stage+merge; retained for tests/offline use) --- /** - * Add a member and create a Commit. + * Add a member and create a Commit, advancing the local epoch immediately. + * + * **Production callers MUST use [stageAddMember] + [mergeStagedCommit] + * instead.** The commit bytes returned here are wrapped on the wire with + * the *post-commit* exporter key, which is wrong — other existing members + * at epoch N cannot decrypt it, so the commit is unprocessable by anyone + * except the new member (who doesn't need it because their Welcome already + * carries the post-commit state). This entry point is retained only for + * unit tests and scenarios where outer framing isn't used. */ suspend fun addMember( nostrGroupId: HexKey, @@ -366,14 +378,16 @@ class MlsGroupManager( ): CommitResult = mutex.withLock { val group = requireGroup(nostrGroupId) - retainEpochSecrets(nostrGroupId, group) + val retainedBefore = group.retainedSecrets() val result = group.addMember(keyPackageBytes) + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) result } /** - * Remove a member and create a Commit. + * Remove a member and create a Commit. See [addMember] for the production + * caveat — use [stageRemoveMember] + [mergeStagedCommit] on the wire. */ suspend fun removeMember( nostrGroupId: HexKey, @@ -381,27 +395,25 @@ class MlsGroupManager( ): CommitResult = mutex.withLock { val group = requireGroup(nostrGroupId) - retainEpochSecrets(nostrGroupId, group) + val retainedBefore = group.retainedSecrets() val result = group.removeMember(targetLeafIndex) + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) result } /** - * Rotate the signing key within a group and commit. - * - * Per MIP-00, members SHOULD self-update within 24 hours of joining. - * This creates an Update proposal with a fresh signing key and commits it. - * - * @param nostrGroupId hex-encoded Nostr group ID - * @return the [CommitResult] to publish + * Rotate the signing key within a group and commit. See [addMember] for + * the production caveat — use [stageRotateSigningKey] + [mergeStagedCommit] + * on the wire. */ suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult = mutex.withLock { val group = requireGroup(nostrGroupId) - retainEpochSecrets(nostrGroupId, group) + val retainedBefore = group.retainedSecrets() group.proposeSigningKeyRotation() val result = group.commit() + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) result } @@ -410,12 +422,8 @@ class MlsGroupManager( * Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal. * Creates the proposal, commits it, and persists the new state. * - * Authorization (MIP-01): extension updates require admin privileges once - * the group has at least one admin. During bootstrap — before any - * `admin_pubkeys` are configured — any member may seed the initial - * extension set (e.g. the creator installing the first - * [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with - * themselves as admin). + * See [addMember] for the production caveat — use [stageUpdateGroupExtensions] + * + [mergeStagedCommit] on the wire. */ suspend fun updateGroupExtensions( nostrGroupId: HexKey, @@ -428,13 +436,89 @@ class MlsGroupManager( check(!adminsConfigured || group.isLocalAdmin()) { "MIP-01: only admins may update group extensions" } - retainEpochSecrets(nostrGroupId, group) + val retainedBefore = group.retainedSecrets() group.proposeGroupContextExtensions(extensions) val result = group.commit() + pushRetainedEpoch(nostrGroupId, retainedBefore) persistGroup(nostrGroupId) result } + // --- Staged Member Management (production path; MDK parity) --- + + /** + * Stage an Add-member Commit without advancing the local epoch. + * The returned [StagedCommit.preCommitExporterSecret] is the epoch-N key + * that the outer kind:445 ChaCha20 layer MUST use so that existing + * members at epoch N can decrypt + process it. Call [mergeStagedCommit] + * after the kind:445 has been handed to the transport. + */ + suspend fun stageAddMember( + nostrGroupId: HexKey, + keyPackageBytes: ByteArray, + ): StagedCommit = + mutex.withLock { + requireGroup(nostrGroupId).stageAddMember(keyPackageBytes) + } + + /** + * Stage a Remove-member Commit. See [stageAddMember]. + */ + suspend fun stageRemoveMember( + nostrGroupId: HexKey, + targetLeafIndex: Int, + ): StagedCommit = + mutex.withLock { + requireGroup(nostrGroupId).stageRemoveMember(targetLeafIndex) + } + + /** + * Stage a signing-key rotation Commit. See [stageAddMember]. + */ + suspend fun stageRotateSigningKey(nostrGroupId: HexKey): StagedCommit = + mutex.withLock { + val group = requireGroup(nostrGroupId) + group.proposeSigningKeyRotation() + group.stageCommit() + } + + /** + * Stage a GroupContextExtensions Commit (MIP-01 metadata). See + * [stageAddMember]. + */ + suspend fun stageUpdateGroupExtensions( + nostrGroupId: HexKey, + extensions: List, + ): StagedCommit = + mutex.withLock { + val group = requireGroup(nostrGroupId) + val currentMarmot = group.currentMarmotData() + val adminsConfigured = currentMarmot != null && currentMarmot.adminPubkeys.isNotEmpty() + check(!adminsConfigured || group.isLocalAdmin()) { + "MIP-01: only admins may update group extensions" + } + group.proposeGroupContextExtensions(extensions) + group.stageCommit() + } + + /** + * Merge a previously [stageAddMember]/[stageRemoveMember]/etc. commit, + * advancing the local epoch to N+1. Retains the pre-commit epoch's + * secrets for late-message decryption, then persists. + */ + suspend fun mergeStagedCommit( + nostrGroupId: HexKey, + staged: StagedCommit, + ) = mutex.withLock { + val group = requireGroup(nostrGroupId) + // Retain the outgoing epoch's secrets BEFORE advancing (they live in + // the pre-commit snapshot, which is this group's current state). + val retainedBefore = group.retainedSecrets() + group.mergeStagedCommit(staged) + pushRetainedEpoch(nostrGroupId, retainedBefore) + persistGroup(nostrGroupId) + } + /** * Leave a group (self-remove). * Returns the SelfRemove proposal bytes to publish, then removes @@ -564,12 +648,18 @@ class MlsGroupManager( } } - private fun retainEpochSecrets( + /** + * Push a previously-captured [RetainedEpochSecrets] into the bounded + * retention window. Call after the epoch advance has been applied + * successfully so that failed commits don't pollute the window with + * duplicate current-epoch keys. + */ + private fun pushRetainedEpoch( nostrGroupId: HexKey, - group: MlsGroup, + retainedBefore: RetainedEpochSecrets, ) { val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() } - retained.add(group.retainedSecrets()) + retained.add(retainedBefore) // Trim to retention window (keep only the most recent N-1 epochs) while (retained.size > EPOCH_RETENTION_WINDOW) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt new file mode 100644 index 000000000..f8aa53f0e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.mls.group + +import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext +import com.vitorpamplona.quartz.marmot.mls.messages.Proposal +import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.marmot.mls.schedule.KeyNonceGeneration +import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree + +/** + * Snapshot of every mutable field of [MlsGroup] at a single point in time. + * + * Used to capture state both before a commit (for rollback) and after a commit + * (for later merging via [MlsGroup.mergeStagedCommit]). + * + * Contains secret key material — treat as sensitive. Do not serialize or log. + */ +internal data class MlsGroupSnapshot( + val groupContext: GroupContext, + /** TLS-encoded [com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree] bytes. */ + val treeBytes: ByteArray, + val myLeafIndex: Int, + val epochSecrets: EpochSecrets, + val secretTree: SecretTree, + val initSecret: ByteArray, + val signingPrivateKey: ByteArray, + val encryptionPrivateKey: ByteArray, + val interimTranscriptHash: ByteArray, + val pskStore: Map, + val pendingProposals: List, + val sentKeys: Map, + val pendingSigningKey: ByteArray?, + val pendingEncryptionKey: ByteArray?, + val reInitPending: Proposal.ReInit?, +) + +/** + * Result of staging (but not yet merging) a Commit. + * + * Mirrors the MDK / OpenMLS `add_members` → `merge_pending_commit` flow + * (RFC 9420 §12.4): first the Commit is *computed* from the current group + * state (which remains at epoch N) so that the outer kind:445 can be + * ChaCha20-encrypted with the **pre-commit (epoch N)** exporter secret — + * the key that other existing members still hold. Only after the Commit has + * been handed to the transport does the local group actually advance to + * epoch N+1 via [MlsGroup.mergeStagedCommit]. + * + * [preCommitExporterSecret] is the output of + * `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch — + * exactly the key existing members need to outer-decrypt and process the + * Commit on inbound. + * + * [commitBytes] is the raw `Commit` TLS struct. [framedCommitBytes] is the + * full `MlsMessage(PublicMessage(Commit))` envelope ready for + * ChaCha20-Poly1305 wrapping; publishers MUST use the framed bytes so that + * receivers can recover the sender's leaf index and confirmation tag. + */ +@ConsistentCopyVisibility +data class StagedCommit internal constructor( + /** Pre-commit (epoch N) MLS-Exporter output for outer kind:445 encryption. */ + val preCommitExporterSecret: ByteArray, + /** Pre-commit epoch (N). */ + val preCommitEpoch: Long, + /** Post-commit epoch (N+1). */ + val postCommitEpoch: Long, + /** Raw Commit (RFC 9420 §12.4) bytes, for tests / processCommit. */ + val commitBytes: ByteArray, + /** MlsMessage(PublicMessage(Commit)) envelope for on-the-wire distribution. */ + val framedCommitBytes: ByteArray, + /** Welcome message bytes for any members added by this Commit, else null. */ + val welcomeBytes: ByteArray?, + /** GroupInfo bytes for external joiners, else null. */ + val groupInfoBytes: ByteArray?, + /** Full post-commit snapshot applied by [MlsGroup.mergeStagedCommit]. */ + internal val postState: MlsGroupSnapshot, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StagedCommit) return false + return commitBytes.contentEquals(other.commitBytes) && + framedCommitBytes.contentEquals(other.framedCommitBytes) && + preCommitEpoch == other.preCommitEpoch && + postCommitEpoch == other.postCommitEpoch + } + + override fun hashCode(): Int { + var result = commitBytes.contentHashCode() + result = 31 * result + framedCommitBytes.contentHashCode() + result = 31 * result + preCommitEpoch.hashCode() + result = 31 * result + postCommitEpoch.hashCode() + return result + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index bd574472f..cdddf5da1 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.runBlocking @@ -427,6 +428,180 @@ class MarmotPipelineTest { } } + @Test + fun testStagedAddMemberUsesPreCommitExporter() { + // Regression: before the stage/merge split, MarmotManager.addMember + // was outer-encrypting the kind:445 commit with the POST-commit + // epoch key. Existing members still at epoch N couldn't decrypt it, + // and the newly-added member (joining via Welcome at epoch N+1) + // *could* decrypt it but then failed to apply ("Duplicate encryption + // key: leaf X"). The fix exposes the pre-commit exporter secret via + // StagedCommit so callers can encrypt correctly. + runBlocking { + val manager = createGroupManager() + manager.createGroup(groupId, "alice".encodeToByteArray()) + + val preEpochExporter = manager.exporterSecret(groupId) + val preEpoch = manager.getGroup(groupId)!!.epoch + + val bobBundle = + manager + .getGroup(groupId)!! + .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val staged = manager.stageAddMember(groupId, bobBundle.keyPackage.toTlsBytes()) + + // Stage must NOT have advanced the local epoch yet. + assertEquals(preEpoch, manager.getGroup(groupId)!!.epoch) + // preCommitExporterSecret must equal the pre-stage exporter key. + kotlin.test.assertContentEquals( + preEpochExporter, + staged.preCommitExporterSecret, + ) + // The staged post-commit epoch is N+1. + assertEquals(preEpoch + 1, staged.postCommitEpoch) + assertNotNull(staged.welcomeBytes) + + // Merge advances to N+1. + manager.mergeStagedCommit(groupId, staged) + assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch) + } + } + + @Test + fun testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage() { + // End-to-end regression for the David/Eden/Fred scenario: a group + // creator (Alice) adds two members (Bob then Carol) in sequence and + // then publishes an application message. Both members MUST be able to + // decrypt the message. Before the stage/merge fix, the add-Carol + // commit was outer-encrypted with the post-commit (epoch 2) key, + // leaving Bob stuck at epoch 1 and unable to decrypt any of Alice's + // subsequent application messages. + runBlocking { + val aliceMgr = createGroupManager() + val bobMgr = createGroupManager() + val carolMgr = createGroupManager() + + // Use 32-byte identities so they fit the MarmotGroupData + // admin_pubkeys 32-byte slots. + val aliceIdBytes = ByteArray(32) { 0xA1.toByte() } + val bobIdBytes = ByteArray(32) { 0xB2.toByte() } + val carolIdBytes = ByteArray(32) { 0xC3.toByte() } + aliceMgr.createGroup(groupId, aliceIdBytes) + // Install the Marmot group data extension so the Welcome carries + // the NostrGroupData (MlsGroupManager.processWelcome requires it). + aliceMgr.updateGroupExtensions( + nostrGroupId = groupId, + extensions = + listOf( + com.vitorpamplona.quartz.marmot.mip01Groups + .MarmotGroupData( + nostrGroupId = groupId, + adminPubkeys = listOf(aliceIdBytes.toHexKey()), + ).toExtension(), + ), + ) + val aliceGroup = aliceMgr.getGroup(groupId)!! + + // KeyPackages are just MLS artifacts carrying identity + init key; + // createKeyPackage on any group instance produces a usable bundle. + val bobBundle = aliceGroup.createKeyPackage(bobIdBytes, ByteArray(0)) + val carolBundle = aliceGroup.createKeyPackage(carolIdBytes, ByteArray(0)) + + val outbound = MarmotOutboundProcessor(aliceMgr) + + // --- Step 1: Alice adds Bob via stage/merge --- + val stagedBob = aliceMgr.stageAddMember(groupId, bobBundle.keyPackage.toTlsBytes()) + val addBobCommitEvent = + outbound.buildCommitEvent( + nostrGroupId = groupId, + commitBytes = stagedBob.framedCommitBytes, + exporterKey = stagedBob.preCommitExporterSecret, + ) + + // Bob joins via Welcome (emulated: we hand him the Welcome bytes). + bobMgr.processWelcome(stagedBob.welcomeBytes!!, bobBundle) + + aliceMgr.mergeStagedCommit(groupId, stagedBob) + val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch + assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch) + + // --- Step 2: Alice adds Carol via stage/merge --- + val stagedCarol = aliceMgr.stageAddMember(groupId, carolBundle.keyPackage.toTlsBytes()) + val addCarolCommitEvent = + outbound.buildCommitEvent( + nostrGroupId = groupId, + commitBytes = stagedCarol.framedCommitBytes, + exporterKey = stagedCarol.preCommitExporterSecret, + ) + + // Carol joins via Welcome at epoch 2. + carolMgr.processWelcome(stagedCarol.welcomeBytes!!, carolBundle) + + // *** The key assertion ***: Bob (still at epoch 1) receives the + // add-Carol kind:445 and must be able to: + // (a) outer-decrypt using his current (epoch 1) exporter key, + // (b) parse the MlsMessage → PublicMessage → COMMIT, + // (c) apply the commit and advance to epoch 2. + val bobExporterAtE1 = bobMgr.exporterSecret(groupId) + kotlin.test.assertContentEquals( + bobExporterAtE1, + stagedCarol.preCommitExporterSecret, + "Bob's epoch-1 exporter must match the pre-commit key used to " + + "outer-encrypt the add-Carol commit; otherwise Bob cannot " + + "decrypt and will be stuck on the old epoch.", + ) + + val decryptedMlsBytes = + GroupEventEncryption.decrypt( + addCarolCommitEvent.signedEvent.content, + bobExporterAtE1, + ) + val mlsMessage = + com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(decryptedMlsBytes), + ) + val publicMessage = + com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage + .decodeTls( + com.vitorpamplona.quartz.marmot.mls.codec + .TlsReader(mlsMessage.payload), + ) + bobMgr.processCommit( + nostrGroupId = groupId, + commitBytes = publicMessage.content, + senderLeafIndex = publicMessage.sender.leafIndex, + confirmationTag = publicMessage.confirmationTag!!, + ) + + aliceMgr.mergeStagedCommit(groupId, stagedCarol) + val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch + assertEquals(epochAfterAddBob + 1, epochAfterAddCarol) + assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch) + assertEquals(epochAfterAddCarol, carolMgr.getGroup(groupId)!!.epoch) + + // --- Step 3: Alice sends an application message. Both members must decrypt. --- + val msg = "Hi from Alice" + val appEvent = + outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray()) + + val bobKey = bobMgr.exporterSecret(groupId) + val carolKey = carolMgr.exporterSecret(groupId) + val aliceKey = aliceMgr.exporterSecret(groupId) + kotlin.test.assertContentEquals(aliceKey, bobKey, "Bob must share Alice's epoch-2 key") + kotlin.test.assertContentEquals(aliceKey, carolKey, "Carol must share Alice's epoch-2 key") + + val bobMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, bobKey) + val bobDecrypted = bobMgr.decrypt(groupId, bobMlsBytes) + assertEquals(msg, bobDecrypted.content.decodeToString()) + + val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey) + val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes) + assertEquals(msg, carolDecrypted.content.decodeToString()) + } + } + @Test fun testFullRoundtripEncryptDecrypt() { runBlocking { From b039543807357893d4251b58b08e4214f5cc412b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 00:34:07 +0000 Subject: [PATCH 28/34] fix(marmot): revert snapshot-based stage/merge; capture pre-commit key in CommitResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot/restore machinery added in the previous commit (ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit` introduced a tree-rebuild path (RatchetTree.decodeTls followed by a fresh SecretTree construction) that, in production, caused a recursive StackOverflowError in `SecretTree.getNodeSecret` during encryption after a second add-member cycle. The unit test flow did not exercise it and couldn't reproduce. Keep the underlying fix — outer-encrypt outbound kind:445 commits with the PRE-commit (epoch-N) exporter secret — but deliver it with far less machinery: - `CommitResult.preCommitExporterSecret` now carries the `MLS-Exporter("marmot", "group-event", 32)` value captured inside `MlsGroup.commit()` BEFORE any state mutation. It's the key existing members still at epoch N hold, so the outer kind:445 wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity). - `MlsGroup.commit()` captures this exporter once at the top of the function, threads it into the returned `CommitResult`. - `MarmotManager.addMember / removeMember / updateGroupMetadata` read `commitResult.preCommitExporterSecret` and pass it to `MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`. The eager `group.addMember` / etc. continue to advance the local epoch immediately — the same behavior as before the previous commit, which was proven safe under production load. Removed: - `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` / `stageRemoveMember` and their snapshot/restore helpers. - `MlsGroupSnapshot` / `StagedCommit` types. - `MlsGroupManager.stageAddMember` / `stageRemoveMember` / `stageRotateSigningKey` / `stageUpdateGroupExtensions` / `mergeStagedCommit`. - `tree` back to `val` in `MlsGroup`. Kept from the previous commit: - `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)` with default falling back to `groupManager.exporterSecret(...)`. - `MlsGroupManager.processCommit` retains epoch secrets AFTER successful apply (was a secondary bug polluting the retention window with duplicates of the current key when a commit failed). - `pushRetainedEpoch` helper that accepts a pre-captured RetainedEpochSecrets, used throughout instead of the old `retainEpochSecrets(nostrGroupId, group)`. Test updated: - `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now uses the eager `addMember` path and asserts that the member still at the old epoch can outer-decrypt with the pre-commit key shipped back via `CommitResult`. Also decrypts Alice's self-echo and sends multiple follow-up messages — this is the scenario that triggered the production stack overflow before the revert. - `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms `CommitResult.preCommitExporterSecret` equals the exporter the group had immediately before the call, and that the post-commit exporter differs from it (proves we actually rotated). https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL --- .../amethyst/commons/marmot/MarmotManager.kt | 47 +++-- .../quartz/marmot/mls/group/MlsGroup.kt | 168 ++---------------- .../marmot/mls/group/MlsGroupManager.kt | 107 ++--------- .../quartz/marmot/mls/group/StagedCommit.kt | 112 ------------ .../quartz/marmot/mls/messages/Commit.kt | 12 ++ .../quartz/marmot/MarmotPipelineTest.kt | 94 ++++++---- 6 files changed, 117 insertions(+), 423 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt 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 635ae97ea..1e62a6efc 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 @@ -178,39 +178,34 @@ class MarmotManager( "KeyPackage credential identity does not match memberPubKey" } - // Stage-then-merge (MDK / RFC 9420 §12.4): build the kind:445 with the - // PRE-commit epoch's exporter secret so that other existing members - // can outer-decrypt and process it. Only advance our own epoch once - // the framed commit has been handed to the outbound path. - val staged = groupManager.stageAddMember(nostrGroupId, keyPackageBytes) + // Per RFC 9420 §12.4 (and MDK), the outbound kind:445 MUST be + // outer-encrypted with the pre-commit (epoch-N) exporter secret so + // that other existing members still at epoch N can decrypt and + // process the commit. CommitResult.preCommitExporterSecret carries + // that key; the local group state has already advanced to N+1 by + // the time addMember returns, so we can't read it from the group + // any more. + val commitResult = groupManager.addMember(nostrGroupId, keyPackageBytes) val commitEvent = outboundProcessor.buildCommitEvent( nostrGroupId = nostrGroupId, - commitBytes = staged.framedCommitBytes, - exporterKey = staged.preCommitExporterSecret, + commitBytes = commitResult.framedCommitBytes, + exporterKey = commitResult.preCommitExporterSecret, ) // The published kind:445 will echo back from the relay — without this // dedup our own inbound pipeline would try to re-apply a commit whose - // epoch we've already merged below. + // epoch we've already merged. inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) - val welcomeBytes = - staged.welcomeBytes - ?: throw IllegalStateException( - "stageAddMember did not produce a Welcome for $memberPubKey", - ) val welcomeDelivery = - welcomeSender.wrapWelcomeBytes( - welcomeBytes = welcomeBytes, + welcomeSender.wrapWelcome( + commitResult = commitResult, recipientPubKey = memberPubKey, keyPackageEventId = keyPackageEventId, relays = relays, nostrGroupId = nostrGroupId, ) - // Advance local epoch now that the commit + welcome have been built. - groupManager.mergeStagedCommit(nostrGroupId, staged) - return Pair(commitEvent, welcomeDelivery) } @@ -286,15 +281,14 @@ class MarmotManager( nostrGroupId: HexKey, targetLeafIndex: Int, ): OutboundGroupEvent { - val staged = groupManager.stageRemoveMember(nostrGroupId, targetLeafIndex) + val commitResult = groupManager.removeMember(nostrGroupId, targetLeafIndex) val commitEvent = outboundProcessor.buildCommitEvent( nostrGroupId = nostrGroupId, - commitBytes = staged.framedCommitBytes, - exporterKey = staged.preCommitExporterSecret, + commitBytes = commitResult.framedCommitBytes, + exporterKey = commitResult.preCommitExporterSecret, ) inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) - groupManager.mergeStagedCommit(nostrGroupId, staged) return commitEvent } @@ -306,16 +300,15 @@ class MarmotManager( nostrGroupId: HexKey, metadata: MarmotGroupData, ): OutboundGroupEvent { - val staged = - groupManager.stageUpdateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) + val commitResult = + groupManager.updateGroupExtensions(nostrGroupId, listOf(metadata.toExtension())) val commitEvent = outboundProcessor.buildCommitEvent( nostrGroupId = nostrGroupId, - commitBytes = staged.framedCommitBytes, - exporterKey = staged.preCommitExporterSecret, + commitBytes = commitResult.framedCommitBytes, + exporterKey = commitResult.preCommitExporterSecret, ) inboundProcessor.markEventProcessed(commitEvent.signedEvent.id) - groupManager.mergeStagedCommit(nostrGroupId, staged) return commitEvent } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 9bd7f44a5..a8d99382e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -98,7 +98,7 @@ import com.vitorpamplona.quartz.utils.mac.MacInstance */ class MlsGroup private constructor( private var groupContext: GroupContext, - private var tree: RatchetTree, + private val tree: RatchetTree, private var myLeafIndex: Int, private var epochSecrets: EpochSecrets, private var secretTree: SecretTree, @@ -199,125 +199,6 @@ class MlsGroup private constructor( var reInitPending: Proposal.ReInit? = null private set - // --- Stage/Merge Infrastructure (RFC 9420 §12.4 / MDK parity) --- - - /** - * Capture a complete snapshot of every mutable field so this group can be - * restored atomically. Used by [stageCommit] to rewind after computing a - * Commit so the outer code can encrypt the kind:445 with the pre-commit - * exporter secret before advancing locally. - */ - private fun captureSnapshot(): MlsGroupSnapshot { - val treeWriter = TlsWriter() - tree.encodeTls(treeWriter) - return MlsGroupSnapshot( - groupContext = groupContext, - treeBytes = treeWriter.toByteArray(), - myLeafIndex = myLeafIndex, - epochSecrets = epochSecrets, - secretTree = secretTree, - initSecret = initSecret, - signingPrivateKey = signingPrivateKey, - encryptionPrivateKey = encryptionPrivateKey, - interimTranscriptHash = interimTranscriptHash, - pskStore = pskStore.toMap(), - pendingProposals = pendingProposals.toList(), - sentKeys = sentKeys.toMap(), - pendingSigningKey = pendingSigningKey, - pendingEncryptionKey = pendingEncryptionKey, - reInitPending = reInitPending, - ) - } - - /** - * Overwrite every mutable field of this group from a prior snapshot. - * See [captureSnapshot] for the use case. - */ - private fun restoreSnapshot(s: MlsGroupSnapshot) { - groupContext = s.groupContext - tree = RatchetTree.decodeTls(TlsReader(s.treeBytes)) - myLeafIndex = s.myLeafIndex - epochSecrets = s.epochSecrets - // secretTree is keyed on encryption_secret + leafCount; rebuilding from - // the snapshot's encryption_secret avoids relying on SecretTree being - // a pure data class, which it currently is, but keeps us robust. - secretTree = SecretTree(s.epochSecrets.encryptionSecret, tree.leafCount) - initSecret = s.initSecret - signingPrivateKey = s.signingPrivateKey - encryptionPrivateKey = s.encryptionPrivateKey - interimTranscriptHash = s.interimTranscriptHash - pskStore.clear() - pskStore.putAll(s.pskStore) - pendingProposals.clear() - pendingProposals.addAll(s.pendingProposals) - sentKeys.clear() - sentKeys.putAll(s.sentKeys) - pendingSigningKey = s.pendingSigningKey - pendingEncryptionKey = s.pendingEncryptionKey - reInitPending = s.reInitPending - } - - /** - * Build a Commit from the currently pending proposals **without** advancing - * the local epoch. The caller can then outer-encrypt the kind:445 using - * [StagedCommit.preCommitExporterSecret] — which is still the current - * epoch's key — and call [mergeStagedCommit] to advance locally only after - * the kind:445 has been handed to the transport. - * - * This mirrors OpenMLS / MDK's `create_commit` + `merge_pending_commit` - * pair and is the only correct way to encrypt a Commit: existing members - * still at epoch N need the pre-commit exporter secret to decrypt the - * outer layer, while the new member joining via Welcome does not need to - * process this Commit at all. - * - * On success, the group state is unchanged. - * On failure, the group state is unchanged. - */ - fun stageCommit(): StagedCommit { - val preCommitSnapshot = captureSnapshot() - val preCommitExporter = exporterSecret("marmot", "group-event".encodeToByteArray(), 32) - val preCommitEpoch = groupContext.epoch - - val committed: CommitResult = - try { - commit() - } catch (e: Exception) { - // commit() may have mutated fields before throwing — always roll back. - restoreSnapshot(preCommitSnapshot) - throw e - } - - val postCommitSnapshot = captureSnapshot() - val postCommitEpoch = groupContext.epoch - - // Rewind to pre-commit state so the outer code sees epoch N until it - // explicitly calls mergeStagedCommit. - restoreSnapshot(preCommitSnapshot) - - return StagedCommit( - preCommitExporterSecret = preCommitExporter, - preCommitEpoch = preCommitEpoch, - postCommitEpoch = postCommitEpoch, - commitBytes = committed.commitBytes, - framedCommitBytes = committed.framedCommitBytes, - welcomeBytes = committed.welcomeBytes, - groupInfoBytes = committed.groupInfoBytes, - postState = postCommitSnapshot, - ) - } - - /** - * Apply a previously [stageCommit]ed transition, advancing this group from - * epoch N to epoch N+1. Call once the Commit has been successfully handed - * to the transport. - */ - fun mergeStagedCommit(staged: StagedCommit) { - check(groupContext.epoch == staged.preCommitEpoch) { - "mergeStagedCommit: group at epoch ${groupContext.epoch} but staged commit is for epoch ${staged.preCommitEpoch}" - } - restoreSnapshot(staged.postState) - } - /** * Register a pre-shared key for use in PSK proposals. */ @@ -522,6 +403,13 @@ class MlsGroup private constructor( // (i.e. no remaining member appears in the post-commit admin list). enforceNoAdminDepletion(proposals) + // Capture the pre-commit exporter secret BEFORE any mutation. + // Publishers of the outbound kind:445 MUST outer-encrypt with this + // key (epoch N) so that other existing members at epoch N can decrypt + // and process the commit. See CommitResult.preCommitExporterSecret. + val preCommitExporterSecret = + exporterSecret("marmot", "group-event".encodeToByteArray(), 32) + val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) } // Check if we need an UpdatePath (required unless only SelfRemove) @@ -682,6 +570,7 @@ class MlsGroup private constructor( welcomeBytes = welcomeBytes, groupInfoBytes = null, framedCommitBytes = framedCommitBytes, + preCommitExporterSecret = preCommitExporterSecret, ) } @@ -2190,54 +2079,25 @@ class MlsGroup private constructor( /** * Add a member to the group by their KeyPackage. - * Creates and applies a Commit with an Add proposal. - * - * This advances the local epoch eagerly; the returned [CommitResult] is - * encrypted on the wire with the **post-commit** exporter key, which is - * only correct for tests / offline MLS scenarios. Production callers MUST - * use [stageAddMember] + [mergeStagedCommit] so the kind:445 can be - * wrapped with the pre-commit exporter key, matching the MDK reference - * and allowing other existing members to actually decrypt and process the - * commit. + * Creates and applies a Commit with an Add proposal. The resulting + * [CommitResult.preCommitExporterSecret] is the key the outer kind:445 + * MUST be encrypted with (RFC 9420 §12.4 + MDK parity). */ fun addMember(keyPackageBytes: ByteArray): CommitResult { proposeAdd(keyPackageBytes) return commit() } - /** - * Stage an Add-member Commit without advancing the local epoch. - * Returned [StagedCommit] carries the pre-commit exporter secret that the - * outer kind:445 layer MUST be encrypted with (RFC 9420 §12.4 + MDK). - * Call [mergeStagedCommit] after the commit has been handed to the - * transport to advance locally. - */ - fun stageAddMember(keyPackageBytes: ByteArray): StagedCommit { - proposeAdd(keyPackageBytes) - return stageCommit() - } - /** * Remove a member from the group. - * Creates and applies a Commit with a Remove proposal. - * - * See [addMember] for why production callers should prefer - * [stageRemoveMember] instead. + * Creates and applies a Commit with a Remove proposal. See [addMember] + * for the pre-commit exporter key contract on the returned [CommitResult]. */ fun removeMember(targetLeafIndex: Int): CommitResult { proposeRemove(targetLeafIndex) return commit() } - /** - * Stage a Remove-member Commit without advancing the local epoch. - * See [stageAddMember] for the rationale. - */ - fun stageRemoveMember(targetLeafIndex: Int): StagedCommit { - proposeRemove(targetLeafIndex) - return stageCommit() - } - /** * Remove self from the group. * 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 bea9ca8ac..7fddab22b 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 @@ -359,18 +359,16 @@ class MlsGroupManager( null } - // --- Member Management (atomic stage+merge; retained for tests/offline use) --- + // --- Member Management --- /** - * Add a member and create a Commit, advancing the local epoch immediately. + * Add a member and create a Commit. * - * **Production callers MUST use [stageAddMember] + [mergeStagedCommit] - * instead.** The commit bytes returned here are wrapped on the wire with - * the *post-commit* exporter key, which is wrong — other existing members - * at epoch N cannot decrypt it, so the commit is unprocessable by anyone - * except the new member (who doesn't need it because their Welcome already - * carries the post-commit state). This entry point is retained only for - * unit tests and scenarios where outer framing isn't used. + * The returned [CommitResult.preCommitExporterSecret] is the key the + * outbound kind:445 MUST be outer-encrypted with (RFC 9420 §12.4 + MDK + * parity). The local group state advances to the new epoch before this + * function returns; publishers get one shot at the correct pre-commit + * key via the [CommitResult] payload. */ suspend fun addMember( nostrGroupId: HexKey, @@ -386,8 +384,8 @@ class MlsGroupManager( } /** - * Remove a member and create a Commit. See [addMember] for the production - * caveat — use [stageRemoveMember] + [mergeStagedCommit] on the wire. + * Remove a member and create a Commit. See [addMember] for the + * pre-commit exporter key contract on the returned [CommitResult]. */ suspend fun removeMember( nostrGroupId: HexKey, @@ -403,9 +401,10 @@ class MlsGroupManager( } /** - * Rotate the signing key within a group and commit. See [addMember] for - * the production caveat — use [stageRotateSigningKey] + [mergeStagedCommit] - * on the wire. + * Rotate the signing key within a group and commit. + * + * Per MIP-00, members SHOULD self-update within 24 hours of joining. + * See [addMember] for the pre-commit exporter key contract. */ suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult = mutex.withLock { @@ -420,10 +419,7 @@ class MlsGroupManager( /** * Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal. - * Creates the proposal, commits it, and persists the new state. - * - * See [addMember] for the production caveat — use [stageUpdateGroupExtensions] - * + [mergeStagedCommit] on the wire. + * See [addMember] for the pre-commit exporter key contract. */ suspend fun updateGroupExtensions( nostrGroupId: HexKey, @@ -444,81 +440,6 @@ class MlsGroupManager( result } - // --- Staged Member Management (production path; MDK parity) --- - - /** - * Stage an Add-member Commit without advancing the local epoch. - * The returned [StagedCommit.preCommitExporterSecret] is the epoch-N key - * that the outer kind:445 ChaCha20 layer MUST use so that existing - * members at epoch N can decrypt + process it. Call [mergeStagedCommit] - * after the kind:445 has been handed to the transport. - */ - suspend fun stageAddMember( - nostrGroupId: HexKey, - keyPackageBytes: ByteArray, - ): StagedCommit = - mutex.withLock { - requireGroup(nostrGroupId).stageAddMember(keyPackageBytes) - } - - /** - * Stage a Remove-member Commit. See [stageAddMember]. - */ - suspend fun stageRemoveMember( - nostrGroupId: HexKey, - targetLeafIndex: Int, - ): StagedCommit = - mutex.withLock { - requireGroup(nostrGroupId).stageRemoveMember(targetLeafIndex) - } - - /** - * Stage a signing-key rotation Commit. See [stageAddMember]. - */ - suspend fun stageRotateSigningKey(nostrGroupId: HexKey): StagedCommit = - mutex.withLock { - val group = requireGroup(nostrGroupId) - group.proposeSigningKeyRotation() - group.stageCommit() - } - - /** - * Stage a GroupContextExtensions Commit (MIP-01 metadata). See - * [stageAddMember]. - */ - suspend fun stageUpdateGroupExtensions( - nostrGroupId: HexKey, - extensions: List, - ): StagedCommit = - mutex.withLock { - val group = requireGroup(nostrGroupId) - val currentMarmot = group.currentMarmotData() - val adminsConfigured = currentMarmot != null && currentMarmot.adminPubkeys.isNotEmpty() - check(!adminsConfigured || group.isLocalAdmin()) { - "MIP-01: only admins may update group extensions" - } - group.proposeGroupContextExtensions(extensions) - group.stageCommit() - } - - /** - * Merge a previously [stageAddMember]/[stageRemoveMember]/etc. commit, - * advancing the local epoch to N+1. Retains the pre-commit epoch's - * secrets for late-message decryption, then persists. - */ - suspend fun mergeStagedCommit( - nostrGroupId: HexKey, - staged: StagedCommit, - ) = mutex.withLock { - val group = requireGroup(nostrGroupId) - // Retain the outgoing epoch's secrets BEFORE advancing (they live in - // the pre-commit snapshot, which is this group's current state). - val retainedBefore = group.retainedSecrets() - group.mergeStagedCommit(staged) - pushRetainedEpoch(nostrGroupId, retainedBefore) - persistGroup(nostrGroupId) - } - /** * Leave a group (self-remove). * Returns the SelfRemove proposal bytes to publish, then removes diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt deleted file mode 100644 index f8aa53f0e..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/StagedCommit.kt +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.marmot.mls.group - -import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext -import com.vitorpamplona.quartz.marmot.mls.messages.Proposal -import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets -import com.vitorpamplona.quartz.marmot.mls.schedule.KeyNonceGeneration -import com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree - -/** - * Snapshot of every mutable field of [MlsGroup] at a single point in time. - * - * Used to capture state both before a commit (for rollback) and after a commit - * (for later merging via [MlsGroup.mergeStagedCommit]). - * - * Contains secret key material — treat as sensitive. Do not serialize or log. - */ -internal data class MlsGroupSnapshot( - val groupContext: GroupContext, - /** TLS-encoded [com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree] bytes. */ - val treeBytes: ByteArray, - val myLeafIndex: Int, - val epochSecrets: EpochSecrets, - val secretTree: SecretTree, - val initSecret: ByteArray, - val signingPrivateKey: ByteArray, - val encryptionPrivateKey: ByteArray, - val interimTranscriptHash: ByteArray, - val pskStore: Map, - val pendingProposals: List, - val sentKeys: Map, - val pendingSigningKey: ByteArray?, - val pendingEncryptionKey: ByteArray?, - val reInitPending: Proposal.ReInit?, -) - -/** - * Result of staging (but not yet merging) a Commit. - * - * Mirrors the MDK / OpenMLS `add_members` → `merge_pending_commit` flow - * (RFC 9420 §12.4): first the Commit is *computed* from the current group - * state (which remains at epoch N) so that the outer kind:445 can be - * ChaCha20-encrypted with the **pre-commit (epoch N)** exporter secret — - * the key that other existing members still hold. Only after the Commit has - * been handed to the transport does the local group actually advance to - * epoch N+1 via [MlsGroup.mergeStagedCommit]. - * - * [preCommitExporterSecret] is the output of - * `MLS-Exporter("marmot", "group-event", 32)` at the pre-commit epoch — - * exactly the key existing members need to outer-decrypt and process the - * Commit on inbound. - * - * [commitBytes] is the raw `Commit` TLS struct. [framedCommitBytes] is the - * full `MlsMessage(PublicMessage(Commit))` envelope ready for - * ChaCha20-Poly1305 wrapping; publishers MUST use the framed bytes so that - * receivers can recover the sender's leaf index and confirmation tag. - */ -@ConsistentCopyVisibility -data class StagedCommit internal constructor( - /** Pre-commit (epoch N) MLS-Exporter output for outer kind:445 encryption. */ - val preCommitExporterSecret: ByteArray, - /** Pre-commit epoch (N). */ - val preCommitEpoch: Long, - /** Post-commit epoch (N+1). */ - val postCommitEpoch: Long, - /** Raw Commit (RFC 9420 §12.4) bytes, for tests / processCommit. */ - val commitBytes: ByteArray, - /** MlsMessage(PublicMessage(Commit)) envelope for on-the-wire distribution. */ - val framedCommitBytes: ByteArray, - /** Welcome message bytes for any members added by this Commit, else null. */ - val welcomeBytes: ByteArray?, - /** GroupInfo bytes for external joiners, else null. */ - val groupInfoBytes: ByteArray?, - /** Full post-commit snapshot applied by [MlsGroup.mergeStagedCommit]. */ - internal val postState: MlsGroupSnapshot, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is StagedCommit) return false - return commitBytes.contentEquals(other.commitBytes) && - framedCommitBytes.contentEquals(other.framedCommitBytes) && - preCommitEpoch == other.preCommitEpoch && - postCommitEpoch == other.postCommitEpoch - } - - override fun hashCode(): Int { - var result = commitBytes.contentHashCode() - result = 31 * result + framedCommitBytes.contentHashCode() - result = 31 * result + preCommitEpoch.hashCode() - result = 31 * result + postCommitEpoch.hashCode() - return result - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt index a0b4d5d9d..624dd8fff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/messages/Commit.kt @@ -107,6 +107,18 @@ data class CommitResult( * Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)). */ val framedCommitBytes: ByteArray = commitBytes, + /** + * `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit** + * epoch (N) — the key the group had when this commit was computed, before + * local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap + * the commit with this key (RFC 9420 §12.4 and MDK parity). Using the + * post-commit (N+1) key makes the commit unreadable to existing members + * still at epoch N — the exact scenario that caused Eden to stall at + * epoch 1 and never decrypt any of David's subsequent messages. + * + * Empty by default for the test-only entry points that don't need it. + */ + val preCommitExporterSecret: ByteArray = ByteArray(0), ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index cdddf5da1..1aae80232 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -429,14 +429,13 @@ class MarmotPipelineTest { } @Test - fun testStagedAddMemberUsesPreCommitExporter() { - // Regression: before the stage/merge split, MarmotManager.addMember - // was outer-encrypting the kind:445 commit with the POST-commit - // epoch key. Existing members still at epoch N couldn't decrypt it, - // and the newly-added member (joining via Welcome at epoch N+1) - // *could* decrypt it but then failed to apply ("Duplicate encryption - // key: leaf X"). The fix exposes the pre-commit exporter secret via - // StagedCommit so callers can encrypt correctly. + fun testAddMemberExposesPreCommitExporterSecret() { + // Regression: before the fix, MarmotManager.addMember outer-encrypted + // the kind:445 commit with the POST-commit (epoch N+1) exporter key, + // which meant existing members at epoch N couldn't decrypt it. The + // fix captures the pre-commit key inside MlsGroup.commit() and ships + // it back via CommitResult.preCommitExporterSecret so MarmotManager + // can pass it to the outbound builder. runBlocking { val manager = createGroupManager() manager.createGroup(groupId, "alice".encodeToByteArray()) @@ -448,22 +447,24 @@ class MarmotPipelineTest { manager .getGroup(groupId)!! .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) - val staged = manager.stageAddMember(groupId, bobBundle.keyPackage.toTlsBytes()) + val result = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) - // Stage must NOT have advanced the local epoch yet. - assertEquals(preEpoch, manager.getGroup(groupId)!!.epoch) - // preCommitExporterSecret must equal the pre-stage exporter key. + // Local state advanced to N+1. + assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch) + // But the returned pre-commit exporter secret matches what the + // group held BEFORE advancing — the key existing members still at + // epoch N are holding. kotlin.test.assertContentEquals( preEpochExporter, - staged.preCommitExporterSecret, + result.preCommitExporterSecret, ) - // The staged post-commit epoch is N+1. - assertEquals(preEpoch + 1, staged.postCommitEpoch) - assertNotNull(staged.welcomeBytes) - - // Merge advances to N+1. - manager.mergeStagedCommit(groupId, staged) - assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch) + // The post-commit exporter (what groupManager.exporterSecret now + // returns) MUST differ — otherwise we haven't actually rotated. + kotlin.test.assertFalse( + manager.exporterSecret(groupId).contentEquals(preEpochExporter), + "post-commit exporter key must differ from the pre-commit one", + ) + assertNotNull(result.welcomeBytes) } } @@ -509,33 +510,35 @@ class MarmotPipelineTest { val outbound = MarmotOutboundProcessor(aliceMgr) - // --- Step 1: Alice adds Bob via stage/merge --- - val stagedBob = aliceMgr.stageAddMember(groupId, bobBundle.keyPackage.toTlsBytes()) + // --- Step 1: Alice adds Bob. CommitResult carries the pre-commit + // exporter secret so buildCommitEvent can outer-encrypt with + // the epoch-N (pre-Bob) key that Bob-less Alice held. --- + val addBobResult = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) val addBobCommitEvent = outbound.buildCommitEvent( nostrGroupId = groupId, - commitBytes = stagedBob.framedCommitBytes, - exporterKey = stagedBob.preCommitExporterSecret, + commitBytes = addBobResult.framedCommitBytes, + exporterKey = addBobResult.preCommitExporterSecret, ) // Bob joins via Welcome (emulated: we hand him the Welcome bytes). - bobMgr.processWelcome(stagedBob.welcomeBytes!!, bobBundle) + bobMgr.processWelcome(addBobResult.welcomeBytes!!, bobBundle) - aliceMgr.mergeStagedCommit(groupId, stagedBob) val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch) - // --- Step 2: Alice adds Carol via stage/merge --- - val stagedCarol = aliceMgr.stageAddMember(groupId, carolBundle.keyPackage.toTlsBytes()) + // --- Step 2: Alice adds Carol. addCarolResult.preCommitExporterSecret + // is the epoch-N (2-member) key that Bob still holds. --- + val addCarolResult = aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes()) val addCarolCommitEvent = outbound.buildCommitEvent( nostrGroupId = groupId, - commitBytes = stagedCarol.framedCommitBytes, - exporterKey = stagedCarol.preCommitExporterSecret, + commitBytes = addCarolResult.framedCommitBytes, + exporterKey = addCarolResult.preCommitExporterSecret, ) - // Carol joins via Welcome at epoch 2. - carolMgr.processWelcome(stagedCarol.welcomeBytes!!, carolBundle) + // Carol joins via Welcome at the new epoch. + carolMgr.processWelcome(addCarolResult.welcomeBytes!!, carolBundle) // *** The key assertion ***: Bob (still at epoch 1) receives the // add-Carol kind:445 and must be able to: @@ -545,10 +548,10 @@ class MarmotPipelineTest { val bobExporterAtE1 = bobMgr.exporterSecret(groupId) kotlin.test.assertContentEquals( bobExporterAtE1, - stagedCarol.preCommitExporterSecret, - "Bob's epoch-1 exporter must match the pre-commit key used to " + - "outer-encrypt the add-Carol commit; otherwise Bob cannot " + - "decrypt and will be stuck on the old epoch.", + addCarolResult.preCommitExporterSecret, + "Bob's current exporter (pre-add-Carol) must match the " + + "pre-commit key used to outer-encrypt the add-Carol commit; " + + "otherwise Bob cannot decrypt and will be stuck on the old epoch.", ) val decryptedMlsBytes = @@ -575,7 +578,6 @@ class MarmotPipelineTest { confirmationTag = publicMessage.confirmationTag!!, ) - aliceMgr.mergeStagedCommit(groupId, stagedCarol) val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch assertEquals(epochAfterAddBob + 1, epochAfterAddCarol) assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch) @@ -599,6 +601,24 @@ class MarmotPipelineTest { val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey) val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes) assertEquals(msg, carolDecrypted.content.decodeToString()) + + // --- Alice also receives her own echo (as the app does via the + // relay round-trip); this exercises the secretTree-based decrypt + // path with myLeafIndex != sender or when sentKeys misses. + val aliceMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, aliceKey) + val aliceDecrypted = aliceMgr.decrypt(groupId, aliceMlsBytes) + assertEquals(msg, aliceDecrypted.content.decodeToString()) + + // Send a second and third message to stress the secretTree ratchet + // and ensure getNodeSecret stays terminating across generations. + for (i in 1..3) { + val m = "msg-$i" + val ev = outbound.buildGroupEventFromBytes(groupId, m.encodeToByteArray()) + val keyNow = aliceMgr.exporterSecret(groupId) + val bytes = GroupEventEncryption.decrypt(ev.signedEvent.content, keyNow) + val dec = bobMgr.decrypt(groupId, bytes) + assertEquals(m, dec.content.decodeToString()) + } } } From 900d953fb196fd1120a8233199900c7bf8df63ca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 01:05:52 +0000 Subject: [PATCH 29/34] fix(live-chat): keep Top Zappers chips the same height when anonymous The "Anonymous" chip rendered without a 24dp UserPicture, so its pill shrank to the text's height and broke horizontal alignment with the avatar chips in the row. Give the chip row a minimum height equal to the avatar size so text-only variants stay visually aligned. https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi --- .../nip53LiveActivities/header/LiveStreamTopZappers.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt index 1624e3f51..439ef3cb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveStreamTopZappers.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -137,7 +138,12 @@ private fun ZapperPill( modifier = clickModifier, ) { Row( - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + // Min height matches the avatar so text-only chips (Anonymous) stay aligned + // with the avatar chips in the horizontal row. + modifier = + Modifier + .heightIn(min = Size24dp) + .padding(horizontal = 6.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { From 76da39cc2adb9067f8a716152e6ba46a21f8fed5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 21 Apr 2026 01:12:52 +0000 Subject: [PATCH 30/34] New Crowdin translations by GitHub Action --- .../src/main/res/values-zh-rCN/strings.xml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3692563a7..331435854 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -271,6 +271,7 @@ 因此聊天信息会随着时间的推移而消失。 公共聊天 MLS 组 + 尚无消息 公开聊天元数据 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 @@ -390,6 +391,26 @@ 开启 已关闭 徽章 + 社区 + 新建社区 + 编辑社区 + 社区名 + 描述 + 规则 (可选) + 添加封面图像 + 轻触选取一张照片 — 它将上传到您的媒体服务器。 + 协管 + 协管可以批准帖子。您始终是协管。 + 添加协管 + 姓名, npub, 或 NIP-05 + 所有者 + 中继 + 选择托管请求、批准或社区作者元数据的中继。 + 任何 + 作者 + 请求 + 批准 + 没有社区匹配此过滤器。 已收到 我的 授予的 @@ -452,6 +473,20 @@ 查看链接 查看话题标签 您还没有任何书签列表。轻按下面的新建按钮制作一个。 + 兴趣集 + 您还没有任何兴趣集。轻按下面的新建按钮制作一个。 + 新的兴趣集 + 新的兴趣集 + 集合名称 + 我的兴趣 + 添加话题标签 + 私密 + %1$d 个话题标签 + 兴趣集操作 + 重命名 + 克隆 + 添加话题标签 + 切换公共/私密 私密帖子 私密帖子(%1$s) 公开帖子 @@ -954,6 +989,7 @@ 主题 图像预览 视频播放 + 自动播放视频 URL 预览 沉浸式滚动 滚动时隐藏导航栏 @@ -1024,6 +1060,7 @@ 暗色、亮色或系统主题 自动加载图像和 GIF 自动播放视频和 GIF + 视频在屏幕上可见时自动播放视频 显示 URL 预览 何时加载图像 复制 Stack @@ -1525,6 +1562,7 @@ 选择一个用于过滤订阅源的列表 话题标签 + 兴趣集 位置 社区 列表 From 621fe9c8c56a06acdc7fb5de0307764f7771797e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 01:49:05 +0000 Subject: [PATCH 31/34] fix(marmot): derive SecretTree leaf secrets by walking down from root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fred (third member of a three-member group, `leafIndex = 2`) crashed with `StackOverflowError` on ART / NPE on JVM the moment he tried to send his first application message. The recursion / null is in `SecretTree.getNodeSecret`, which assumes `nodeIndex` is always a direct child of `BinaryTree.parent(nodeIndex)`: val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount)) val parentSecret = getNodeSecret(parentIdx) val leftIdx = BinaryTree.left(parentIdx) val rightIdx = BinaryTree.right(parentIdx) treeSecrets[leftIdx] = leftSecret treeSecrets[rightIdx] = rightSecret return treeSecrets[nodeIndex]!! That invariant holds only for power-of-two trees. For Fred (leafCount = 3, nodeCount = 5), `BinaryTree.parent(4, 5)` walks via `parentInRange(5, 5)` and returns **3** (the root) — skipping the virtual intermediate node 5 that would normally sit between node 4 and the root in a 4-leaf tree. But `left(3) = 1` and `right(3) = 5`; neither is 4. The cache fills entries 1 and 5 from the root's secret, node 4's entry is never populated, and the final `treeSecrets[nodeIndex]!!` throws NPE. On Android the error path instead re-enters `getNodeSecret` until the stack is exhausted. The fix walks **down** from the root to the target leaf, picking left or right at each step based on `targetNode < currentNode` (holds in left-balanced MLS trees) and using `BinaryTree.left` / `BinaryTree.right` which correctly descend through virtual intermediate nodes. The node-level cache is no longer needed — `getOrInitSender` caches the per-sender ratchet state once, so re-deriving the leaf secret each time a new sender is initialized costs a handful of HKDF calls at worst. Side effects (all in the right direction): - Re-enabled 6 previously `@Ignore`d tests in `MlsGroupLifecycleTest` and `MlsGroupEdgeCaseTest` that exercised exactly the three-member / non-full-tree path this fix addresses: `testThreeMemberGroup_SequentialAdditions`, `testExternalJoin_ZaraJoinsViaGroupInfo`, `testExternalJoin_ExporterSecretsAgree`, `testSigningKeyRotation_EpochAdvances`, `testEncryptDecryptAfterSigningKeyRotation`, `testPskProposal_EpochAdvancesWithPsk`. They now pass. - `testEmptyCommit_AdvancesEpoch` and `testMultipleEpochTransitions_EncryptDecryptStillWorks` remain `@Ignore`d — they hit a **different** pre-existing bug where Alice's and Bob's exporter secrets diverge after a no-proposal commit (commit with only an UpdatePath). Unrelated to the non-full-tree fix; tracked separately. I retagged them with an explanatory note instead of the old "see testThreeMemberGroup_SequentialAdditions" reference. - Fixed an unrelated typo in `testMultipleEpochTransitions_EncryptDecryptStillWorks` that asserted `7L` and then `6L` for the same epoch value. Regression test: - `testFredEncryptsAfterJoiningThreeMemberGroup` creates a three-member group via the production path (Alice adds Bob, then adds Fred; Bob processes the add-Fred commit), then has Fred encrypt an application message and asserts both Alice and Bob can decrypt it. Without the fix this throws NPE / stack-overflows at `encrypt()`. https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL --- .../quartz/marmot/mls/schedule/SecretTree.kt | 75 ++++++++++--------- .../quartz/marmot/MarmotPipelineTest.kt | 61 +++++++++++++++ .../quartz/marmot/mls/MlsGroupEdgeCaseTest.kt | 9 ++- .../marmot/mls/MlsGroupLifecycleTest.kt | 12 ++- 4 files changed, 112 insertions(+), 45 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt index 3766a396f..5c3c28e66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt @@ -47,9 +47,6 @@ class SecretTree( private val encryptionSecret: ByteArray, private val leafCount: Int, ) { - /** Cached tree node secrets, computed lazily */ - private val treeSecrets = mutableMapOf() - /** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */ private val senderState = mutableMapOf() @@ -72,11 +69,6 @@ class SecretTree( const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000 } - init { - // Seed the root - treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret - } - /** * Get the next (key, nonce) for application messages from a given sender. */ @@ -252,36 +244,51 @@ class SecretTree( } /** - * Derive the leaf secret from the encryption secret using the tree structure. + * Derive the leaf secret from the encryption secret by walking DOWN the + * binary tree from the root to the target leaf (RFC 9420 §9). + * + * At each step we pick left or right based on which subtree contains the + * target. In an MLS left-balanced tree the left-subtree node indices are + * always strictly less than the current node, and the right-subtree + * indices strictly greater — so the direction is simply + * `targetNode < currentNode`. + * + * This also correctly handles non-power-of-2 leaf counts (3, 5, 6, 7, 9 + * …) where the rightmost leaves live beneath "virtual" intermediate + * nodes that are beyond `nodeCount`. Walking down still succeeds because + * [BinaryTree.left] / [BinaryTree.right] give the correct descendants + * even for virtual nodes, and the target leaf is reachable via a chain + * of left/right steps that mixes real and virtual intermediates. + * + * The previous implementation derived the target's secret from + * `BinaryTree.parent(target)` which, for leaves on the right edge of a + * non-full tree, returned a high ancestor (often the root) that is NOT + * the target's direct parent. Its `left()` and `right()` then pointed + * at different nodes entirely, the target stayed un-cached, and the + * follow-up `return treeSecrets[nodeIndex]!!` either threw NPE (on + * JVM) or — when repeatedly re-entered — recursed into + * [getNodeSecret] until the stack was exhausted (on ART). */ private fun getLeafSecret(leafIndex: Int): ByteArray { - val nodeIndex = BinaryTree.leafToNode(leafIndex) - return getNodeSecret(nodeIndex) - } + val targetNode = BinaryTree.leafToNode(leafIndex) + val rootIdx = BinaryTree.root(leafCount) + var currentSecret = encryptionSecret + var currentNode = rootIdx - /** - * Recursively derive a node's secret from its parent in the secret tree. - */ - private fun getNodeSecret(nodeIndex: Int): ByteArray { - treeSecrets[nodeIndex]?.let { return it } + while (currentNode != targetNode) { + val goLeft = targetNode < currentNode + val label = if (goLeft) "left" else "right" + currentSecret = + MlsCryptoProvider.expandWithLabel( + currentSecret, + "tree", + label.encodeToByteArray(), + MlsCryptoProvider.HASH_OUTPUT_LENGTH, + ) + currentNode = if (goLeft) BinaryTree.left(currentNode) else BinaryTree.right(currentNode) + } - val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount)) - val parentSecret = getNodeSecret(parentIdx) - - // Derive left and right children secrets - val leftIdx = BinaryTree.left(parentIdx) - val rightIdx = BinaryTree.right(parentIdx) - - val leftSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "left".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH) - val rightSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "right".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH) - - treeSecrets[leftIdx] = leftSecret - treeSecrets[rightIdx] = rightSecret - - // Clear parent secret for forward secrecy - treeSecrets.remove(parentIdx) - - return treeSecrets[nodeIndex]!! + return currentSecret } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index 1aae80232..c74b1e6f0 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -428,6 +428,67 @@ class MarmotPipelineTest { } } + @Test + fun testFredEncryptsAfterJoiningThreeMemberGroup() { + // Reproduces the production StackOverflowError: the last-joined member + // (Fred at leafIndex=2 in a 3-member group) attempts to encrypt his + // first message and the SecretTree.getNodeSecret recursion never + // terminates. + runBlocking { + val aliceMgr = createGroupManager() + val bobMgr = createGroupManager() + val fredMgr = createGroupManager() + + val aliceIdBytes = ByteArray(32) { 0xA1.toByte() } + aliceMgr.createGroup(groupId, aliceIdBytes) + aliceMgr.updateGroupExtensions( + nostrGroupId = groupId, + extensions = + listOf( + com.vitorpamplona.quartz.marmot.mip01Groups + .MarmotGroupData( + nostrGroupId = groupId, + adminPubkeys = listOf(aliceIdBytes.toHexKey()), + ).toExtension(), + ), + ) + val aliceGroup = aliceMgr.getGroup(groupId)!! + val bobBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xB2.toByte() }, ByteArray(0)) + val fredBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xF3.toByte() }, ByteArray(0)) + + // Alice adds Bob. + val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle) + + // Alice adds Fred (he joins at leafIndex=2, in a 3-leaf tree). + val addFred = aliceMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes()) + fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle) + // Bob (existing member at the pre-add-Fred epoch) receives and + // applies Alice's add-Fred commit to reach the same epoch as + // Alice + Fred. Without this, Bob can't decrypt Fred's subsequent + // application messages. + bobMgr.processCommit( + nostrGroupId = groupId, + commitBytes = addFred.commitBytes, + senderLeafIndex = aliceMgr.getGroup(groupId)!!.leafIndex, + confirmationTag = ByteArray(0), + ) + + // Sanity: Fred is at leafIndex=2, leafCount=3. + assertEquals(2, fredMgr.getGroup(groupId)!!.leafIndex) + + // This is where production crashed with StackOverflowError. + val fredMsg = "hi from fred" + val fredCiphertext = fredMgr.encrypt(groupId, fredMsg.encodeToByteArray()) + + // And Alice & Bob must be able to decrypt it. + val aliceDecrypted = aliceMgr.decrypt(groupId, fredCiphertext) + assertEquals(fredMsg, aliceDecrypted.content.decodeToString()) + val bobDecrypted = bobMgr.decrypt(groupId, fredCiphertext) + assertEquals(fredMsg, bobDecrypted.content.decodeToString()) + } + } + @Test fun testAddMemberExposesPreCommitExporterSecret() { // Regression: before the fix, MarmotManager.addMember outer-encrypted diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt index 4647c3960..d77ff1e89 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupEdgeCaseTest.kt @@ -225,9 +225,11 @@ class MlsGroupEdgeCaseTest { // 6. Multiple epochs of encrypt/decrypt // ----------------------------------------------------------------------- - // BUG: processCommit key derivation diverges — commit_secret decryption from - // UpdatePath does not correctly derive matching epoch secrets between commit() - // and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions. + // BUG: same empty-commit (no proposals, pure UpdatePath) divergence as + // MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The + // SecretTree.getNodeSecret non-full-tree fix doesn't address this — after + // 5 empty commits Alice and Bob's ratchet secrets diverge and AEAD + // decryption fails with "Tag mismatch". Tracked separately. @Ignore @Test fun testMultipleEpochTransitions_EncryptDecryptStillWorks() { @@ -242,7 +244,6 @@ class MlsGroupEdgeCaseTest { bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0)) } - assertEquals(7L, alice.epoch) // epoch 0 + addMember(1) + 5 commits = 6... wait // epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6 assertEquals(6L, alice.epoch) assertEquals(alice.epoch, bob.epoch) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt index c16a0256e..3a829233d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupLifecycleTest.kt @@ -176,7 +176,6 @@ class MlsGroupLifecycleTest { // because the commit_secret decryption from the UpdatePath does not // correctly walk the ratchet tree to find the common ancestor's path secret. // This causes AEAD decryption failures on cross-member messages. - @Ignore @Test fun testThreeMemberGroup_SequentialAdditions() { // Alice creates the group @@ -243,7 +242,6 @@ class MlsGroupLifecycleTest { // ----------------------------------------------------------------------- // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions - @Ignore @Test fun testExternalJoin_ZaraJoinsViaGroupInfo() { val alice = MlsGroup.create("alice".encodeToByteArray()) @@ -266,7 +264,6 @@ class MlsGroupLifecycleTest { } // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions - @Ignore @Test fun testExternalJoin_ExporterSecretsAgree() { val alice = MlsGroup.create("alice".encodeToByteArray()) @@ -312,7 +309,6 @@ class MlsGroupLifecycleTest { // ----------------------------------------------------------------------- // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions - @Ignore @Test fun testSigningKeyRotation_EpochAdvances() { val alice = MlsGroup.create("alice".encodeToByteArray()) @@ -342,7 +338,6 @@ class MlsGroupLifecycleTest { } // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions - @Ignore @Test fun testEncryptDecryptAfterSigningKeyRotation() { val alice = MlsGroup.create("alice".encodeToByteArray()) @@ -412,7 +407,6 @@ class MlsGroupLifecycleTest { // ----------------------------------------------------------------------- // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions - @Ignore @Test fun testPskProposal_EpochAdvancesWithPsk() { val alice = MlsGroup.create("alice".encodeToByteArray()) @@ -472,7 +466,11 @@ class MlsGroupLifecycleTest { // 12. Empty commit (no proposals, just UpdatePath for forward secrecy) // ----------------------------------------------------------------------- - // BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions + // BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and + // Bob's exporter secrets after processCommit. Unrelated to the SecretTree + // non-full-tree fix — the other @Ignored "processCommit diverges" tests in + // this file and MlsGroupEdgeCaseTest now pass, but this one still fails. + // Tracked separately. @Ignore @Test fun testEmptyCommit_AdvancesEpoch() { From 7ee5f64fd8931b1467f2a1a456bf6c68f99226a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 02:31:12 +0000 Subject: [PATCH 32/34] fix(marmot): reject past/future-epoch commits instead of partially applying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a group creator restarts the app, their own relay-echoed kind:445 commit events (add-member, updateGroupExtensions, etc.) stop being filtered by `inboundProcessor.processedEventIds` — that in-memory dedup set doesn't survive process death. The echo then: 1. Outer-decrypts via a retained epoch key (we still hold those). 2. Gets parsed as a `PublicMessage` with `contentType = COMMIT`. 3. Flows into `applyCommit` → `groupManager.processCommit` → `group.processCommit`. `MlsGroup.processCommit` is not atomic: it runs proposal application, transcript-hash recomputation, epoch advance, key-schedule derivation, and SecretTree rebuild **in place** before reaching the final confirmation-tag check. If that check fails — which it always does for an already-applied commit, because the tag was computed against the original epoch's confirmation key and we're now several epochs past — the exception fires after the mutations, leaving the group context at a bogus "epoch + 1" with a completely new exporter secret. Net result on the wire: the creator's next outbound kind:445 is encrypted with this rogue exporter secret. Every other member is at the real epoch, so they all log "Outer decryption failed with current and N retained epoch key(s)" and no one receives the message. The creator's own self-echo still decrypts because his sentKeys / self-state are internally consistent; only external observers are cut off. Fix: in `MarmotInboundProcessor.applyCommit`, after parsing the PublicMessage header but **before** calling `processCommit`, compare `pubMsg.epoch` with the current local `group.epoch`. - `pubMsg.epoch < current` → commit is from the past (either our own already-applied echo or a stale relay replay). Return `GroupEventResult.Duplicate` and don't touch local state. - `pubMsg.epoch > current` → we're behind; the inbound pipeline upstream needs to feed us the intervening commits in order. Return an error describing the gap without touching local state. - Otherwise → proceed to `processCommit` as before. Making `processCommit` itself atomic would be nicer, but is a much larger refactor touching tree mutations, key-schedule state, and per-sender ratchets. The epoch check catches the production scenario at the right boundary — we never call into `processCommit` with bytes that we know won't apply cleanly. Regression test: - `testCreatorDoesNotDoubleApplyOwnCommitAfterRestart`: David creates a group, adds Eden and Fred, persists. Simulates David's app restart by rebuilding the `MlsGroupManager` from the store. Then feeds David's own add-Eden commit back through the inbound pipeline — exactly what a relay echo would deliver. Asserts the result is `Duplicate`, and that David's epoch and exporter secret are unchanged afterwards. Before the fix, his epoch advances by one and his exporter secret drifts, which is exactly what the production logs showed. - `testCreatorCanSendMessageAfterRestart`: save/restore round-trip sanity check that the restored manager's exporter secret matches what the members still at the same epoch hold, and that the first post-restart message from the creator is decryptable by an un-restarted member. https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL --- .../quartz/marmot/MarmotInboundProcessor.kt | 49 ++++- .../quartz/marmot/MarmotPipelineTest.kt | 189 ++++++++++++++++++ 2 files changed, 227 insertions(+), 11 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 933e8ba0c..251ceef7d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -467,17 +467,44 @@ class MarmotInboundProcessor( WireFormat.PUBLIC_MESSAGE -> { val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload)) val tag = pubMsg.confirmationTag - if (tag == null) { - GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag") - } else { - groupManager.processCommit( - nostrGroupId = groupId, - commitBytes = pubMsg.content, - senderLeafIndex = pubMsg.sender.leafIndex, - confirmationTag = tag, - ) - val group = groupManager.getGroup(groupId) - GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) + val currentEpoch = groupManager.getGroup(groupId)?.epoch + when { + tag == null -> { + GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag") + } + + // Reject commits that are not for our current epoch. + // Happens most commonly when our own already-applied + // commit is echoed back from the relay after an app + // restart (the in-memory dedup set is cleared), and + // the outer layer decrypts via a retained epoch key. + // Calling `processCommit` on a past-epoch commit + // partially mutates tree / groupContext / epochSecrets + // before throwing on the confirmation-tag check, + // leaving the local state diverged from every other + // member's — they then can't decrypt anything we + // send next. + currentEpoch != null && pubMsg.epoch < currentEpoch -> { + GroupEventResult.Duplicate(groupId) + } + + currentEpoch != null && pubMsg.epoch > currentEpoch -> { + GroupEventResult.Error( + groupId, + "Commit epoch ${pubMsg.epoch} is ahead of local epoch $currentEpoch; ignoring", + ) + } + + else -> { + groupManager.processCommit( + nostrGroupId = groupId, + commitBytes = pubMsg.content, + senderLeafIndex = pubMsg.sender.leafIndex, + confirmationTag = tag, + ) + val group = groupManager.getGroup(groupId) + GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0) + } } } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt index c74b1e6f0..a819ad36e 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/MarmotPipelineTest.kt @@ -428,6 +428,195 @@ class MarmotPipelineTest { } } + @Test + fun testCreatorDoesNotDoubleApplyOwnCommitAfterRestart() { + // Root cause: after David restarts, his own add-member kind:445 that + // got echoed back from the relay is no longer marked as "already + // processed" (the dedup set lives in memory and isn't persisted). + // The inbound path then outer-decrypts it via a retained epoch key + // and feeds the commit into group.processCommit — which is NOT + // atomic, so it partially advances David's local state before failing + // with "Confirmation tag verification failed" (the tag was computed + // at the original epoch; David is already two epochs past). + // + // After this partial apply, David's epoch / tree / exporter_secret + // drift forward by one epoch while Eden and Fred stay put. Any + // subsequent message David sends uses a key nobody else has, and + // they log "Outer decryption failed with current and N retained + // epoch key(s)". + runBlocking { + val davidStore = TestGroupStateStore() + val davidMgr = MlsGroupManager(davidStore) + val edenMgr = MlsGroupManager(TestGroupStateStore()) + val fredMgr = MlsGroupManager(TestGroupStateStore()) + val davidId = ByteArray(32) { 0xD1.toByte() } + val edenId = ByteArray(32) { 0xE2.toByte() } + val fredId = ByteArray(32) { 0xF3.toByte() } + + davidMgr.createGroup(groupId, davidId) + davidMgr.updateGroupExtensions( + groupId, + listOf( + com.vitorpamplona.quartz.marmot.mip01Groups + .MarmotGroupData( + nostrGroupId = groupId, + adminPubkeys = listOf(davidId.toHexKey()), + ).toExtension(), + ), + ) + val davidGroup = davidMgr.getGroup(groupId)!! + val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0)) + val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0)) + + val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes()) + edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle) + val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes()) + fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle) + edenMgr.processCommit( + groupId, + addFred.commitBytes, + davidMgr.getGroup(groupId)!!.leafIndex, + ByteArray(0), + ) + + val preRestartEpoch = davidMgr.getGroup(groupId)!!.epoch + val preRestartExporter = davidMgr.exporterSecret(groupId) + + // Simulate David's app restart. + val davidMgr2 = MlsGroupManager(davidStore) + davidMgr2.restoreAll() + + // Now replay David's own echoed add-Eden commit that's still on + // the relay. After restart, the inbound dedup set is empty so + // the inbound pipeline does the full outer-decrypt via retained + // keys + processCommit on an already-applied commit. + val inbound = + com.vitorpamplona.quartz.marmot + .MarmotInboundProcessor( + groupManager = davidMgr2, + keyPackageRotationManager = + com.vitorpamplona.quartz.marmot.mip00KeyPackages + .KeyPackageRotationManager(), + ) + val outbound = MarmotOutboundProcessor(davidMgr2) + + // Re-encrypt the add-Eden framed commit with the pre-commit key + // so it looks exactly like what the relay would re-deliver. + val echoed = + outbound.buildCommitEvent( + nostrGroupId = groupId, + commitBytes = addEden.framedCommitBytes, + exporterKey = addEden.preCommitExporterSecret, + ) + val result = inbound.processGroupEvent(echoed.signedEvent) + assertIs( + result, + "echoed already-applied commit must be treated as a no-op Duplicate", + ) + + val postEchoEpoch = davidMgr2.getGroup(groupId)!!.epoch + val postEchoExporter = davidMgr2.exporterSecret(groupId) + + assertEquals( + preRestartEpoch, + postEchoEpoch, + "processing our own already-applied commit echo must NOT advance the local epoch", + ) + kotlin.test.assertContentEquals( + preRestartExporter, + postEchoExporter, + "processing our own already-applied commit echo must NOT change the exporter secret", + ) + } + } + + @Test + fun testCreatorCanSendMessageAfterRestart() { + // Reproduces production symptom: David creates a group, adds Eden and + // Fred, sends messages — all fine. After David restarts the app + // (simulated here by save-state + fresh MlsGroupManager + restoreAll), + // his outbound kind:445 outer ChaCha20 layer suddenly uses a + // different key than Eden/Fred, and they fail with + // "Outer decryption failed with current and N retained epoch key(s)". + runBlocking { + val davidStore = TestGroupStateStore() + val davidMgr = MlsGroupManager(davidStore) + val edenMgr = MlsGroupManager(TestGroupStateStore()) + val fredMgr = MlsGroupManager(TestGroupStateStore()) + + val davidId = ByteArray(32) { 0xD1.toByte() } + val edenId = ByteArray(32) { 0xE2.toByte() } + val fredId = ByteArray(32) { 0xF3.toByte() } + + davidMgr.createGroup(groupId, davidId) + davidMgr.updateGroupExtensions( + nostrGroupId = groupId, + extensions = + listOf( + com.vitorpamplona.quartz.marmot.mip01Groups + .MarmotGroupData( + nostrGroupId = groupId, + adminPubkeys = listOf(davidId.toHexKey()), + ).toExtension(), + ), + ) + val davidGroup = davidMgr.getGroup(groupId)!! + val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0)) + val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0)) + + // David adds Eden. + val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes()) + edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle) + + // David adds Fred; Eden processes Alice's add-Fred commit. + val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes()) + fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle) + edenMgr.processCommit( + nostrGroupId = groupId, + commitBytes = addFred.commitBytes, + senderLeafIndex = davidMgr.getGroup(groupId)!!.leafIndex, + confirmationTag = ByteArray(0), + ) + + // Pre-restart sanity: all three share the same outer exporter key. + val preRestartDavidKey = davidMgr.exporterSecret(groupId) + kotlin.test.assertContentEquals( + preRestartDavidKey, + edenMgr.exporterSecret(groupId), + "Eden's exporter key should match David's before restart", + ) + kotlin.test.assertContentEquals( + preRestartDavidKey, + fredMgr.exporterSecret(groupId), + "Fred's exporter key should match David's before restart", + ) + + // Simulate David's app restart: drop his in-memory manager and + // rebuild it from the persisted store. + val davidMgr2 = MlsGroupManager(davidStore) + davidMgr2.restoreAll() + + val postRestartDavidKey = davidMgr2.exporterSecret(groupId) + kotlin.test.assertContentEquals( + preRestartDavidKey, + postRestartDavidKey, + "David's exporter key must survive a save+restore round-trip; " + + "otherwise Eden/Fred can't decrypt his next outer layer.", + ) + + // David sends a message post-restart; Eden and Fred must decrypt. + val outbound = MarmotOutboundProcessor(davidMgr2) + val msg = "hi after restart" + val postRestartEvent = + outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray()) + + val edenKey = edenMgr.exporterSecret(groupId) + val mlsBytesEden = GroupEventEncryption.decrypt(postRestartEvent.signedEvent.content, edenKey) + val edenDecrypted = edenMgr.decrypt(groupId, mlsBytesEden) + assertEquals(msg, edenDecrypted.content.decodeToString()) + } + } + @Test fun testFredEncryptsAfterJoiningThreeMemberGroup() { // Reproduces the production StackOverflowError: the last-joined member From 96cedcbc9bcc7652b3da4b8b72d2b0a87313d42d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 02:56:52 +0000 Subject: [PATCH 33/34] chore(marmot): demote benign inbound failures from WARN to DEBUG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of noisy warnings appear every time an invitee or a restarted client catches up with events still sitting on the relays. Both are actually expected, per-spec behavior — not errors. (a) Outer ChaCha20-Poly1305 decrypt fails with "current and N retained epoch key(s)". A newly-joined member processes the three kind:445s that advanced the group to their current epoch (add-me-commit plus any earlier commits). The commits were outer-encrypted with keys that predate their Welcome, so they never held them — this is MLS forward secrecy working as intended. No state on the receiver's side actually needs to update: they're already at the post-Welcome epoch. (b) "NO matching KeyPackageBundle for eventId=…" after app restart. The gift-wrapped Welcome (kind:1059) is still on the relay and gets redelivered on every subscription refresh. The client's `processedEventIds` dedup set is in-memory, so after a restart the Welcome flows all the way to `processWelcome`, which then fails to find the KeyPackage bundle because it was consumed and marked during the first processing. Both of these fire every time a member opens the app, so the logs currently look like something is broken even when everything is behaving correctly. Fix: - Add `GroupEventResult.UndecryptableOuterLayer(groupId, retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent` and `applyCommit` now call the new `tryDecryptOuterLayer` (which returns `ByteArray?` instead of throwing) and surface this variant when every available key fails. `GroupEventHandler.add` logs it at DEBUG with a one-liner noting "likely from before our join". - Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When `MarmotInboundProcessor.processWelcome` is called with a `hintNostrGroupId` we're already a member of, short-circuit before the KeyPackageBundle lookup and return `AlreadyJoined`. `processMarmotWelcomeFlow` logs at DEBUG. - `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer` as a no-op for subscription-timestamp updates (same as Duplicate/Error/CommitPending), so a unreadable past-epoch event doesn't move the group's `since` forward. No behavioral changes beyond logging level and the short-circuit of a Welcome we already processed (which was throwing and returning Error before). Existing callers that only care about successful `Joined` / successful `CommitProcessed` / `ApplicationMessage` branches are unaffected — the two new variants join the `Duplicate` / `CommitPending` / `Error` quiet side of the sealed class. https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL --- .../loggedIn/DecryptAndIndexProcessor.kt | 19 ++++ .../amethyst/commons/marmot/MarmotManager.kt | 1 + .../quartz/marmot/MarmotInboundProcessor.kt | 93 ++++++++++++++++--- 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 552bd56eb..100760c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -404,6 +404,15 @@ private suspend fun processMarmotWelcomeFlow( } } + is WelcomeResult.AlreadyJoined -> { + // Benign replay of a gift-wrapped Welcome (kind:1059) we already + // processed in a prior session — the relay is just re-delivering + // it after app restart. Log at DEBUG, not WARN. + Log.d("MarmotDbg") { + "processMarmotWelcomeFlow: already joined group=${result.nostrGroupId.take(8)}… — treating Welcome as replay" + } + } + is WelcomeResult.Error -> { Log.w("MarmotDbg") { "processMarmotWelcomeFlow: ERROR ${result.message}" } } @@ -619,6 +628,16 @@ class GroupEventHandler( Log.d("MarmotDbg") { "GroupEventHandler.add: Duplicate kind:445 for group=${result.groupId.take(8)}…" } } + is GroupEventResult.UndecryptableOuterLayer -> { + // Expected for commits + application messages from epochs + // that predate our join — per MLS forward secrecy we + // never held those keys. Not a bug, not a warning. + Log.d("MarmotDbg") { + "GroupEventHandler.add: undecryptable outer layer for group=${result.groupId.take(8)}… " + + "(current + ${result.retainedEpochCount} retained epoch key(s) tried) — likely from before our join" + } + } + is GroupEventResult.Error -> { Log.w("MarmotDbg") { "GroupEventHandler.add: ERROR ${result.message}" } } 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 1e62a6efc..a7410e557 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 @@ -115,6 +115,7 @@ class MarmotManager( is GroupEventResult.CommitPending, is GroupEventResult.Duplicate, + is GroupEventResult.UndecryptableOuterLayer, is GroupEventResult.Error, -> {} } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt index 251ceef7d..1160561d7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotInboundProcessor.kt @@ -79,6 +79,21 @@ sealed class GroupEventResult { val groupId: HexKey, ) : GroupEventResult() + /** + * The outer ChaCha20-Poly1305 layer could not be decrypted with the + * current-epoch exporter key or any retained prior-epoch key. + * + * This is the expected outcome whenever a member receives a kind:445 + * from an epoch before they joined (via Welcome). Per MLS forward + * secrecy, the new member never held those keys, so the bytes are + * unreadable to them — and that is by design, not an error. Callers + * should surface this at DEBUG, not WARN. + */ + data class UndecryptableOuterLayer( + val groupId: HexKey, + val retainedEpochCount: Int, + ) : GroupEventResult() + /** * The event could not be processed. */ @@ -101,6 +116,20 @@ sealed class WelcomeResult { val needsKeyPackageRotation: Boolean, ) : WelcomeResult() + /** + * The Welcome was for a group we're already a member of — benign replay. + * + * Happens after an app restart: the gift-wrapped Welcome (kind:1059) is + * still sitting on the relay and gets redelivered, but the KeyPackage + * bundle it referenced was already consumed and marked. Rather than + * logging a noisy "No matching KeyPackageBundle" error, we detect the + * replay up front by checking `groupManager.isMember(hintNostrGroupId)` + * and return this result. Callers should log at DEBUG. + */ + data class AlreadyJoined( + val nostrGroupId: HexKey, + ) : WelcomeResult() + /** * The Welcome could not be processed. */ @@ -176,15 +205,25 @@ class MarmotInboundProcessor( val result = try { // Step 1: Outer ChaCha20-Poly1305 decryption - val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent()) + val mlsBytes = tryDecryptOuterLayer(groupId, groupEvent.encryptedContent()) + if (mlsBytes == null) { + // Expected when this kind:445 was encrypted with an epoch + // key that predates our join (classical MLS forward + // secrecy), or when the sender's epoch has drifted. Not + // an error — callers should log at DEBUG. + GroupEventResult.UndecryptableOuterLayer( + groupId, + retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size, + ) + } else { + // Step 2: Parse the MLS message + val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) - // Step 2: Parse the MLS message - val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) - - when (mlsMessage.wireFormat) { - WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent) - WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent) - else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}") + when (mlsMessage.wireFormat) { + WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent) + WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent) + else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}") + } } } catch (e: Exception) { GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e) @@ -245,6 +284,22 @@ class MarmotInboundProcessor( "MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}…" } + // Short-circuit if we're already a member of the group the + // Welcome is inviting us to. Happens every time the app + // restarts: the gift-wrapped kind:1059 is still on the relay + // and gets redelivered, but the KeyPackage bundle it + // referenced was already consumed + marked during the first + // processing. Without this check the fallthrough below would + // log a noisy "No matching KeyPackageBundle" warning for what + // is actually a benign replay. + if (hintNostrGroupId != null && groupManager.isMember(hintNostrGroupId)) { + com.vitorpamplona.quartz.utils.Log + .d("MarmotDbg") { + "MarmotInboundProcessor.processWelcome: already a member of group=${hintNostrGroupId.take(8)}… — treating Welcome as replay" + } + return WelcomeResult.AlreadyJoined(hintNostrGroupId) + } + // Find the KeyPackageBundle that was consumed. // // The Welcome's "e" tag carries the *Nostr event id* of the @@ -446,7 +501,12 @@ class MarmotInboundProcessor( commitEvent: GroupEvent, ): GroupEventResult = try { - val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent()) + val mlsBytes = + tryDecryptOuterLayer(groupId, commitEvent.encryptedContent()) + ?: return GroupEventResult.UndecryptableOuterLayer( + groupId, + retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size, + ) val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes)) when (mlsMessage.wireFormat) { @@ -522,11 +582,17 @@ class MarmotInboundProcessor( * * After a commit advances the epoch, late-arriving messages encrypted * with the previous epoch's exporter key would fail without this fallback. + * + * Returns null when neither the current epoch key nor any retained key + * decrypts. This happens normally for commits/application messages from + * epochs that predate our join (we never held those keys), so callers + * should treat null as an expected "nothing to do here" outcome and log + * at DEBUG, not as an error. */ - private fun decryptOuterLayer( + private fun tryDecryptOuterLayer( groupId: HexKey, encryptedContent: String, - ): ByteArray { + ): ByteArray? { // Try current epoch key first try { val exporterKey = groupManager.exporterSecret(groupId) @@ -545,9 +611,6 @@ class MarmotInboundProcessor( } } - // All keys exhausted — throw to let callers produce an error result - throw IllegalStateException( - "Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)", - ) + return null } } From 8ec7da072545c27658b09d05cc57241e4cb982e5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 21 Apr 2026 03:13:18 +0000 Subject: [PATCH 34/34] New Crowdin translations by GitHub Action --- .../src/main/res/values-zh-rCN/strings.xml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3692563a7..331435854 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -271,6 +271,7 @@ 因此聊天信息会随着时间的推移而消失。 公共聊天 MLS 组 + 尚无消息 公开聊天元数据 公开聊天对所有 Nostr 用户都是可见的,任何人都可以: 参与这些聊天。这对特定话题建立的开放式社区来说很重要 @@ -390,6 +391,26 @@ 开启 已关闭 徽章 + 社区 + 新建社区 + 编辑社区 + 社区名 + 描述 + 规则 (可选) + 添加封面图像 + 轻触选取一张照片 — 它将上传到您的媒体服务器。 + 协管 + 协管可以批准帖子。您始终是协管。 + 添加协管 + 姓名, npub, 或 NIP-05 + 所有者 + 中继 + 选择托管请求、批准或社区作者元数据的中继。 + 任何 + 作者 + 请求 + 批准 + 没有社区匹配此过滤器。 已收到 我的 授予的 @@ -452,6 +473,20 @@ 查看链接 查看话题标签 您还没有任何书签列表。轻按下面的新建按钮制作一个。 + 兴趣集 + 您还没有任何兴趣集。轻按下面的新建按钮制作一个。 + 新的兴趣集 + 新的兴趣集 + 集合名称 + 我的兴趣 + 添加话题标签 + 私密 + %1$d 个话题标签 + 兴趣集操作 + 重命名 + 克隆 + 添加话题标签 + 切换公共/私密 私密帖子 私密帖子(%1$s) 公开帖子 @@ -954,6 +989,7 @@ 主题 图像预览 视频播放 + 自动播放视频 URL 预览 沉浸式滚动 滚动时隐藏导航栏 @@ -1024,6 +1060,7 @@ 暗色、亮色或系统主题 自动加载图像和 GIF 自动播放视频和 GIF + 视频在屏幕上可见时自动播放视频 显示 URL 预览 何时加载图像 复制 Stack @@ -1525,6 +1562,7 @@ 选择一个用于过滤订阅源的列表 话题标签 + 兴趣集 位置 社区 列表