fix: track KeyPackage published status and subscribe to own key packages

The publish KeyPackage button was always active because the app didn't
track whether a key package had already been published. This adds:

- hasActiveKeyPackages() to KeyPackageRotationManager and MarmotManager
- hasPublishedKeyPackage() to Account, checking both in-memory bundles
  and the local cache for existing kind:30443 events
- Own key package filter in MarmotSubscriptionManager and the EOSE
  manager so previously published key packages are downloaded from
  relays on app restart
- UI feedback: primary-colored key icon when published, contextual
  empty-state message, and a spinner during publishing

https://claude.ai/code/session_01BVe7aSEWd2KLi5Ks6RZkcc
This commit is contained in:
Claude
2026-04-06 14:43:06 +00:00
parent b1f54a5c07
commit 66e6b0b726
7 changed files with 92 additions and 23 deletions
@@ -130,6 +130,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension
import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType 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.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -1849,6 +1851,26 @@ class Account(
client.publish(event, outboxRelays.flow.value) 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. * Create a new Marmot MLS group.
*/ */
@@ -51,16 +51,17 @@ class MarmotGroupEventsEoseManager(
val manager = key.account.marmotManager ?: return emptyList() val manager = key.account.marmotManager ?: return emptyList()
if (!key.account.isWriteable()) 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() 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) // Send to the home relays (where group events are relayed)
val relays = key.account.homeRelays.flow.value val relays = key.account.homeRelays.flow.value
if (relays.isEmpty()) return emptyList() if (relays.isEmpty()) return emptyList()
return relays.flatMap { relay -> return relays.flatMap { relay ->
marmotFilters.map { filter -> allFilters.map { filter ->
RelayBasedFilter( RelayBasedFilter(
relay = relay, relay = relay,
filter = filter, filter = filter,
@@ -1464,6 +1464,8 @@ class AccountViewModel(
account.publishMarmotKeyPackage() account.publishMarmotKeyPackage()
} }
fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage()
suspend fun leaveMarmotGroup(nostrGroupId: String) { suspend fun leaveMarmotGroup(nostrGroupId: String) {
val relays = account.outboxRelays.flow.value val relays = account.outboxRelays.flow.value
account.leaveMarmotGroup(nostrGroupId, relays) account.leaveMarmotGroup(nostrGroupId, relays)
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape 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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -55,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -77,6 +80,7 @@ fun MarmotGroupListScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var isPublishing by remember { mutableStateOf(false) } var isPublishing by remember { mutableStateOf(false) }
var hasPublishedKeyPackage by remember { mutableStateOf(accountViewModel.hasPublishedKeyPackage()) }
// Load group list // Load group list
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -103,26 +107,41 @@ fun MarmotGroupListScreen(
}, },
title = { Text("Marmot Groups") }, title = { Text("Marmot Groups") },
actions = { actions = {
IconButton( if (isPublishing) {
enabled = !isPublishing, CircularProgressIndicator(
onClick = { modifier = Modifier.padding(12.dp).size(24.dp),
isPublishing = true strokeWidth = 2.dp,
scope.launch(Dispatchers.IO) { strokeCap = StrokeCap.Round,
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",
) )
} 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, style = MaterialTheme.typography.titleMedium,
) )
Text( 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp), modifier = Modifier.padding(top = 4.dp),
@@ -248,6 +248,12 @@ class MarmotManager(
*/ */
fun needsKeyPackageRotation(): Boolean = keyPackageRotationManager.needsRotation() 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. * Check if a specific group membership exists.
*/ */
@@ -140,6 +140,13 @@ class MarmotSubscriptionManager(
MarmotFilters.giftWrapsForUser(userPubKey) 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. * Build a KeyPackage filter for a specific user.
* Used on-demand when inviting a user to a group. * Used on-demand when inviting a user to a group.
@@ -166,6 +173,7 @@ class MarmotSubscriptionManager(
val filters = mutableListOf<Filter>() val filters = mutableListOf<Filter>()
filters.addAll(activeGroupFilters()) filters.addAll(activeGroupFilters())
filters.add(giftWrapFilter()) filters.add(giftWrapFilter())
filters.add(ownKeyPackageFilter())
return filters return filters
} }
@@ -154,6 +154,12 @@ class KeyPackageRotationManager {
*/ */
fun needsRotation(): Boolean = pendingRotations.isNotEmpty() 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. * Rotate a consumed slot: generate a new KeyPackage for the same d-tag.
* *