Merge pull request #2158 from vitorpamplona/claude/fix-keypackage-status-jK921

Add KeyPackage publication status tracking for Marmot groups
This commit is contained in:
Vitor Pamplona
2026-04-06 10:46:16 -04:00
committed by GitHub
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.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.
*/
@@ -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,
@@ -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)
@@ -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),
@@ -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.
*/
@@ -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<Filter>()
filters.addAll(activeGroupFilters())
filters.add(giftWrapFilter())
filters.add(ownKeyPackageFilter())
return filters
}
@@ -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.
*