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 f3f35b6dd..aabbb4236 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -130,6 +130,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.mimeType +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -1849,6 +1851,26 @@ class Account( client.publish(event, outboxRelays.flow.value) } + /** + * Check if a KeyPackage has been published, either locally generated + * in this session or found in the local cache from a previous session. + */ + fun hasPublishedKeyPackage(): Boolean { + // Check in-memory bundles first (current session) + val manager = marmotManager + if (manager != null && manager.hasActiveKeyPackages()) return true + + // Check local cache for our own kind:30443 events (from previous sessions / relay downloads) + val address = + com.vitorpamplona.quartz.nip01Core.core.Address( + KeyPackageEvent.KIND, + signer.pubKey, + KeyPackageUtils.PRIMARY_SLOT, + ) + val note = cache.getAddressableNoteIfExists(address) + return note?.event != null + } + /** * Create a new Marmot MLS group. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 4736334d5..04e5a9dcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -51,16 +51,17 @@ class MarmotGroupEventsEoseManager( val manager = key.account.marmotManager ?: return emptyList() if (!key.account.isWriteable()) return emptyList() - // Build Marmot filters (kind:445 per group) + // Build Marmot filters (kind:445 per group + kind:30443 own key packages) val marmotFilters = manager.subscriptionManager.activeGroupFilters() - if (marmotFilters.isEmpty()) return emptyList() + val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter() + val allFilters = marmotFilters + ownKeyPackageFilter // Send to the home relays (where group events are relayed) val relays = key.account.homeRelays.flow.value if (relays.isEmpty()) return emptyList() return relays.flatMap { relay -> - marmotFilters.map { filter -> + allFilters.map { filter -> RelayBasedFilter( relay = relay, filter = filter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 2506761bb..7b8237a17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1464,6 +1464,8 @@ class AccountViewModel( account.publishMarmotKeyPackage() } + fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage() + suspend fun leaveMarmotGroup(nostrGroupId: String) { val relays = account.outboxRelays.flow.value account.leaveMarmotGroup(nostrGroupId, relays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index 4cca2d246..ec828d736 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -28,6 +28,7 @@ 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 @@ -35,6 +36,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -55,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -77,6 +80,7 @@ fun MarmotGroupListScreen( val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var isPublishing by remember { mutableStateOf(false) } + var hasPublishedKeyPackage by remember { mutableStateOf(accountViewModel.hasPublishedKeyPackage()) } // Load group list LaunchedEffect(Unit) { @@ -103,26 +107,41 @@ fun MarmotGroupListScreen( }, title = { Text("Marmot Groups") }, actions = { - IconButton( - enabled = !isPublishing, - onClick = { - isPublishing = true - scope.launch(Dispatchers.IO) { - try { - accountViewModel.publishMarmotKeyPackage() - snackbarHostState.showSnackbar("KeyPackage published successfully") - } catch (e: Exception) { - snackbarHostState.showSnackbar("Failed to publish KeyPackage: ${e.message}") - } finally { - isPublishing = false - } - } - }, - ) { - Icon( - imageVector = Icons.Default.VpnKey, - contentDescription = "Publish KeyPackage", + if (isPublishing) { + CircularProgressIndicator( + modifier = Modifier.padding(12.dp).size(24.dp), + strokeWidth = 2.dp, + strokeCap = StrokeCap.Round, ) + } else { + IconButton( + onClick = { + isPublishing = true + scope.launch(Dispatchers.IO) { + try { + accountViewModel.publishMarmotKeyPackage() + hasPublishedKeyPackage = true + snackbarHostState.showSnackbar("KeyPackage published successfully") + } catch (e: Exception) { + snackbarHostState.showSnackbar("Failed to publish KeyPackage: ${e.message}") + } finally { + isPublishing = false + } + } + }, + ) { + Icon( + imageVector = Icons.Default.VpnKey, + contentDescription = + if (hasPublishedKeyPackage) "Republish KeyPackage" else "Publish KeyPackage", + tint = + if (hasPublishedKeyPackage) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } }, ) @@ -145,7 +164,12 @@ fun MarmotGroupListScreen( style = MaterialTheme.typography.titleMedium, ) Text( - "Tap the key icon above to publish your KeyPackage and receive invitations.", + text = + if (hasPublishedKeyPackage) { + "Your KeyPackage is published. Waiting for group invitations." + } else { + "Tap the key icon above to publish your KeyPackage and receive invitations." + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp), 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 8122f358c..d9d8cfc35 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 @@ -248,6 +248,12 @@ class MarmotManager( */ fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation() + /** + * Check if there are active (locally generated) KeyPackages. + * Returns true if at least one KeyPackage has been generated and not yet consumed. + */ + fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages() + /** * Check if a specific group membership exists. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt index 9174159ac..b1a74df2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotSubscriptionManager.kt @@ -140,6 +140,13 @@ class MarmotSubscriptionManager( MarmotFilters.giftWrapsForUser(userPubKey) } + /** + * Build a filter for the current user's own KeyPackages (kind:30443). + * Used to discover previously published KeyPackages on relay connect/reconnect, + * so the app can track whether a KeyPackage has already been published. + */ + fun ownKeyPackageFilter(): Filter = MarmotFilters.keyPackagesByAuthor(userPubKey) + /** * Build a KeyPackage filter for a specific user. * Used on-demand when inviting a user to a group. @@ -166,6 +173,7 @@ class MarmotSubscriptionManager( val filters = mutableListOf() filters.addAll(activeGroupFilters()) filters.add(giftWrapFilter()) + filters.add(ownKeyPackageFilter()) return filters } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt index 33e3c81ea..e0c8e82e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRotationManager.kt @@ -154,6 +154,12 @@ class KeyPackageRotationManager { */ fun needsRotation(): Boolean = pendingRotations.isNotEmpty() + /** + * Check if there are any active (non-consumed) KeyPackage bundles. + * Returns true if at least one slot has been generated and not yet consumed. + */ + fun hasActiveKeyPackages(): Boolean = activeBundles.isNotEmpty() + /** * Rotate a consumed slot: generate a new KeyPackage for the same d-tag. *