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 17b411445..025bf0ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1788,23 +1788,32 @@ class Account( // Build filter for the member's KeyPackages val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) - // KeyPackages are published by the invitee to *their own* outbox - // (see publishMarmotKeyPackage), so look there first. Union with - // our own outbox so we still find packages that ended up on a - // shared relay, and as a fallback when we don't know the - // invitee's outbox yet. + // Per MIP-00, invitees advertise the relays that host their + // KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look + // there first, then fall back to the invitee's NIP-65 outbox + // (where KeyPackages typically also land), and finally union + // with our own outbox so we still find packages that ended up + // on a shared relay. val myOutbox = outboxRelays.flow.value + val memberKeyPackageRelays = + ( + cache + .getAddressableNoteIfExists( + com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent + .createAddress(memberPubKey), + )?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent + )?.relays()?.toSet().orEmpty() val memberOutbox = cache .getOrCreateUser(memberPubKey) .outboxRelays() ?.toSet() .orEmpty() - val fetchRelays = memberOutbox + myOutbox + val fetchRelays = memberKeyPackageRelays + memberOutbox + myOutbox Log.d("MarmotDbg") { "fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " + - "(memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" + "(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}" } // Query across the combined relay set @@ -1939,6 +1948,20 @@ class Account( } } + /** + * Relays where this account publishes kind:30443 KeyPackage events. + * + * Per MIP-00, these should match the relays advertised in the user's + * kind:10051 KeyPackage Relay List so that other clients can discover + * and fetch them. Falls back to the standard outbox set when no list + * has been configured yet, since that's also where the user's other + * write-oriented events land. + */ + fun keyPackagePublishRelays(): Set { + val list = keyPackageRelayList.flow.value + return if (list.isNotEmpty()) list else outboxRelays.flow.value + } + /** * Publish or rotate KeyPackage events. */ @@ -1946,13 +1969,13 @@ class Account( val manager = marmotManager ?: return if (!isWriteable()) return - val relays = outboxRelays.flow.value.toList() + val relays = keyPackagePublishRelays() if (manager.needsKeyPackageRotation()) { - val rotatedEvents = manager.rotateConsumedKeyPackages(relays) + val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList()) rotatedEvents.forEach { event -> cache.justConsumeMyOwnEvent(event) - client.publish(event, outboxRelays.flow.value) + client.publish(event, relays) } } } @@ -1964,16 +1987,16 @@ class Account( val manager = marmotManager ?: return if (!isWriteable()) return - val relays = outboxRelays.flow.value.toList() + val relays = keyPackagePublishRelays() Log.d("MarmotDbg") { "publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}" } - val event = manager.generateKeyPackageEvent(relays) + val event = manager.generateKeyPackageEvent(relays.toList()) Log.d("MarmotDbg") { "publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…" } cache.justConsumeMyOwnEvent(event) - client.publish(event, outboxRelays.flow.value) + client.publish(event, relays) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 1bf39cadf..6e336cb68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metada import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -53,6 +54,7 @@ val AccountInfoAndListsFromKeyKinds = StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, + KeyPackageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, BlossomServersEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index 2573f911e..41e08a87b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -47,6 +48,7 @@ val BasicAccountInfoKinds = ContactListEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, + KeyPackageRelayListEvent.KIND, SearchRelayListEvent.KIND, FileServersEvent.KIND, BlossomServersEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index a9acace0e..97a13bac7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -41,6 +42,7 @@ val UserMetadataForKeyKinds = StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, + KeyPackageRelayListEvent.KIND, ) fun filterUserMetadataForKey( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 4d50b5511..4c3d8c673 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -293,6 +294,7 @@ class AccountSessionManager( accountSettings.backupContactList?.let { client.publish(it, toPost) } accountSettings.backupNIP65RelayList?.let { client.publish(it, toPost) } accountSettings.backupDMRelayList?.let { client.publish(it, toPost) } + accountSettings.backupKeyPackageRelayList?.let { client.publish(it, toPost) } accountSettings.backupSearchRelayList?.let { client.publish(it, toPost) } accountSettings.backupIndexRelayList?.let { client.publish(it, toPost) } accountSettings.backupRelayFeedsList?.let { client.publish(it, toPost) } @@ -317,6 +319,10 @@ class AccountSessionManager( ), backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner), backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), + // MIP-00: advertise the default outbox relays as KeyPackage hosts + // so other users can discover and fetch this account's KeyPackage + // events without having to guess where they were published. + backupKeyPackageRelayList = KeyPackageRelayListEvent.create(DefaultNIP65RelaySet.toList(), tempSigner), backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), backupIndexRelayList = IndexerRelayListEvent.create(DefaultIndexerRelayList.toList(), tempSigner), backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner), 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 d390bf1ab..56d164093 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 @@ -1471,6 +1471,27 @@ class AccountViewModel( suspend fun hasPublishedKeyPackage(): Boolean = account.hasPublishedKeyPackage() + /** + * Whether this account has a kind:10051 KeyPackage Relay List (MIP-00) + * advertising where it publishes KeyPackages. + */ + fun hasKeyPackageRelayList(): Boolean = + account.keyPackageRelayList.flow.value + .isNotEmpty() + + /** + * Publishes a kind:10051 KeyPackage Relay List seeded from the account's + * current outbox relays. Used when the user opts in from the + * "Create Group" warning dialog. + */ + suspend fun saveKeyPackageRelayListFromOutbox() { + val outbox = + account.outboxRelays.flow.value + .toList() + if (outbox.isEmpty()) return + account.saveKeyPackageRelayList(outbox) + } + suspend fun leaveMarmotGroup(nostrGroupId: String) { val relays = marmotGroupRelays(nostrGroupId) account.leaveMarmotGroup(nostrGroupId, relays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 77e42d7a1..2c390e93a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -26,10 +26,12 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -55,44 +57,53 @@ fun CreateGroupScreen( ) { var groupName by remember { mutableStateOf("") } var isCreating by remember { mutableStateOf(false) } + var showKeyPackageRelayDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val context = LocalContext.current + fun proceedWithCreate() { + isCreating = true + scope.launch(Dispatchers.IO) { + try { + val nostrGroupId = RandomInstance.bytes(32).toHexKey() + accountViewModel.createMarmotGroup(nostrGroupId) + // Always commit an initial metadata extension so that + // (a) the name (if any) is persisted in MLS extensions + // and survives app restarts, + // (b) the inviter's outbox relays land in the group + // metadata so every member ends up with the same + // canonical relay set for kind:445 — without this, + // invitees would never receive the group's messages. + // Both are handled inside `updateMarmotGroupMetadata`. + accountViewModel.updateMarmotGroupMetadata( + nostrGroupId = nostrGroupId, + name = groupName.trim(), + description = "", + ) + nav.nav(Route.MarmotGroupChat(nostrGroupId)) + } catch (e: Exception) { + isCreating = false + launch(Dispatchers.Main) { + Toast + .makeText( + context, + "Failed to create group: ${e.message}", + Toast.LENGTH_LONG, + ).show() + } + } + } + } + Scaffold( topBar = { CreatingTopBar( onCancel = { nav.popBack() }, onPost = { - isCreating = true - scope.launch(Dispatchers.IO) { - try { - val nostrGroupId = RandomInstance.bytes(32).toHexKey() - accountViewModel.createMarmotGroup(nostrGroupId) - // Always commit an initial metadata extension so that - // (a) the name (if any) is persisted in MLS extensions - // and survives app restarts, - // (b) the inviter's outbox relays land in the group - // metadata so every member ends up with the same - // canonical relay set for kind:445 — without this, - // invitees would never receive the group's messages. - // Both are handled inside `updateMarmotGroupMetadata`. - accountViewModel.updateMarmotGroupMetadata( - nostrGroupId = nostrGroupId, - name = groupName.trim(), - description = "", - ) - nav.nav(Route.MarmotGroupChat(nostrGroupId)) - } catch (e: Exception) { - isCreating = false - launch(Dispatchers.Main) { - Toast - .makeText( - context, - "Failed to create group: ${e.message}", - Toast.LENGTH_LONG, - ).show() - } - } + if (!accountViewModel.hasKeyPackageRelayList()) { + showKeyPackageRelayDialog = true + } else { + proceedWithCreate() } }, isActive = { !isCreating }, @@ -129,4 +140,53 @@ fun CreateGroupScreen( ) } } + + if (showKeyPackageRelayDialog) { + MissingKeyPackageRelayListDialog( + onConfirm = { + showKeyPackageRelayDialog = false + scope.launch(Dispatchers.IO) { + accountViewModel.saveKeyPackageRelayListFromOutbox() + } + proceedWithCreate() + }, + onDismiss = { + showKeyPackageRelayDialog = false + proceedWithCreate() + }, + onCancel = { + showKeyPackageRelayDialog = false + }, + ) + } +} + +@Composable +private fun MissingKeyPackageRelayListDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, + onCancel: () -> Unit, +) { + AlertDialog( + onDismissRequest = onCancel, + title = { Text("KeyPackage Relays not set") }, + text = { + Text( + "You don't have a KeyPackage Relay List yet (MIP-00). " + + "This list tells other people where your KeyPackage is " + + "published so they can invite you to group chats.\n\n" + + "Use your current outbox relays for this?", + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Use outbox relays") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Skip for now") + } + }, + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt index abe1d9438..dbbfff619 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageRelayListEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -70,6 +71,12 @@ class KeyPackageRelayListEvent( createdAt: Long = TimeUtils.now(), ): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): KeyPackageRelayListEvent = signer.sign(createdAt, KIND, createTagArray(relays), "") + suspend fun updateRelayList( earlierVersion: KeyPackageRelayListEvent, relays: List,