This commit is contained in:
Vitor Pamplona
2026-04-15 16:21:22 -04:00
3 changed files with 74 additions and 12 deletions
@@ -1757,10 +1757,23 @@ class Account(
// Build filter for the member's KeyPackages // Build filter for the member's KeyPackages
val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey)
val relays = outboxRelays.flow.value
// Query across outbox relays // KeyPackages are published by the invitee to *their own* outbox
val filterMap = relays.associateWith { listOf(filter) } // (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.
val myOutbox = outboxRelays.flow.value
val memberOutbox =
cache
.getOrCreateUser(memberPubKey)
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays = memberOutbox + myOutbox
// Query across the combined relay set
val filterMap = fetchRelays.associateWith { listOf(filter) }
val event = val event =
client.fetchFirst( client.fetchFirst(
@@ -1784,7 +1797,10 @@ class Account(
kotlin.io.encoding.Base64 kotlin.io.encoding.Base64
.decode(keyPackageBase64) .decode(keyPackageBase64)
val keyPackageEventId = event.id val keyPackageEventId = event.id
val groupRelays = relays.toList() // The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them.
val groupRelays = myOutbox.toList()
addMarmotGroupMember( addMarmotGroupMember(
nostrGroupId = nostrGroupId, nostrGroupId = nostrGroupId,
@@ -1823,9 +1839,32 @@ class Account(
// Publish commit first (critical ordering) // Publish commit first (critical ordering)
client.publish(commitEvent.signedEvent, groupRelays.toSet()) client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send Welcome gift wrap to the new member // Then send the Welcome gift wrap to the new member.
//
// We deliberately do NOT route this through
// computeRelayListToBroadcast(): its GiftWrap branch returns an
// empty relay set when the recipient has no NIP-17 kind:10050 and
// no cached NIP-65 inbox relays, which silently drops the welcome
// and leaves the invitee with no way to discover the group.
//
// Instead, mirror sendNip04PrivateMessage's delivery strategy:
// publish to our own outbox (so we keep a copy and the invitee
// may find it via a shared relay) unioned with the recipient's
// DM inbox relays — User.dmInboxRelays() already falls back
// through NIP-17 kind:10050 → NIP-65 read relays, which is where
// the invitee's AccountGiftWrapsEoseManager actually listens.
if (welcomeDelivery != null) { if (welcomeDelivery != null) {
val relayList = computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent) val recipientInbox =
cache
.getOrCreateUser(memberPubKey)
.dmInboxRelays()
.orEmpty()
val relayList = outboxRelays.flow.value + recipientInbox
if (relayList.isEmpty()) {
Log.w("Marmot") {
"addMarmotGroupMember: no relays to deliver welcome gift wrap to ${memberPubKey.take(8)}"
}
}
client.publish(welcomeDelivery.giftWrapEvent, relayList) client.publish(welcomeDelivery.giftWrapEvent, relayList)
} }
} }
@@ -71,8 +71,10 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -699,11 +701,32 @@ class EventNotificationConsumer(
if (TimeUtils.now() - event.createdAt > CallManager.MAX_EVENT_AGE_SECONDS) return if (TimeUtils.now() - event.createdAt > CallManager.MAX_EVENT_AGE_SECONDS) return
val callerUser = LocalCache.getUserIfExists(event.pubKey) val callerUser = LocalCache.getOrCreateUser(event.pubKey)
val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..."
// If the caller's metadata hasn't been loaded yet (e.g. fresh process from
// a push notification), briefly subscribe to the user finder so we can
// resolve the user's display name instead of showing the raw pubkey.
if (callerUser.metadataOrNull()?.bestName() == null) {
val authorState = UserFinderQueryState(callerUser, account)
try {
Amethyst.instance.sources.userFinder
.subscribe(authorState)
withTimeoutOrNull(5_000L) {
callerUser
.metadata()
.flow
.first { it?.info?.bestName() != null }
}
} finally {
Amethyst.instance.sources.userFinder
.unsubscribe(authorState)
}
}
val callerName = callerUser.toBestDisplayName()
val callerBitmap = val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl -> callerUser.profilePicture()?.let { pictureUrl ->
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try { try {
val request = val request =
@@ -630,12 +630,12 @@ object NotificationUtils {
callerPubKey: String, callerPubKey: String,
context: Context, context: Context,
) { ) {
val callerUser = LocalCache.getUserIfExists(callerPubKey) val callerUser = LocalCache.getOrCreateUser(callerPubKey)
val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..." val callerName = callerUser.toBestDisplayName()
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}" val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap = val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl -> callerUser.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val request = val request =