From da6dae31855dc35af8dfe1963adb113548712fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 19:55:15 +0000 Subject: [PATCH 1/2] fix(amethyst): show caller display name in WebRTC call notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incoming call push notification was falling back to the raw caller hex (or short npub) whenever the caller's User object hadn't been created or their metadata hadn't been loaded yet — which is the common case when the app is woken up by a push notification with a fresh, empty in-memory cache. Switch notifyIncomingCall to use LocalCache.getOrCreateUser, and if the caller's metadata hasn't been loaded, briefly subscribe via UserFinderQueryState (same pattern used by wakeUpFor) and wait up to 5 seconds for metadata to arrive before building the notification. This lets toBestDisplayName() resolve to the user's real name in the notification popup. Also tighten NotificationUtils.showIncomingCallNotification to use getOrCreateUser so that in-app calls fall back to the user's npub display rather than a raw hex prefix when metadata is unavailable. --- .../EventNotificationConsumer.kt | 29 +++++++++++++++++-- .../notifications/NotificationUtils.kt | 6 ++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 739a71ed9..8216d9b68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -71,8 +71,10 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import java.math.BigDecimal import kotlin.coroutines.cancellation.CancellationException @@ -699,11 +701,32 @@ class EventNotificationConsumer( if (TimeUtils.now() - event.createdAt > CallManager.MAX_EVENT_AGE_SECONDS) return - val callerUser = LocalCache.getUserIfExists(event.pubKey) - val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..." + val callerUser = LocalCache.getOrCreateUser(event.pubKey) + + // 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 = - callerUser?.profilePicture()?.let { pictureUrl -> + callerUser.profilePicture()?.let { pictureUrl -> kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { try { val request = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 2c6f66497..53ffd2e60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -630,12 +630,12 @@ object NotificationUtils { callerPubKey: String, context: Context, ) { - val callerUser = LocalCache.getUserIfExists(callerPubKey) - val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..." + val callerUser = LocalCache.getOrCreateUser(callerPubKey) + val callerName = callerUser.toBestDisplayName() val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}" val callerBitmap = - callerUser?.profilePicture()?.let { pictureUrl -> + callerUser.profilePicture()?.let { pictureUrl -> withContext(Dispatchers.IO) { try { val request = From ba06f9a92b06d8fb8abc46d35d2489688b72588a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 20:04:20 +0000 Subject: [PATCH 2/2] fix(marmot): deliver group welcome to the invitee's actual relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in the Marmot "new group → add member" path were preventing the invited user from ever discovering the group, even though the commit applied cleanly on the creator's side. 1. fetchKeyPackageAndAddMember() searched for the invitee's KeyPackage on the *creator's* own outbox relays. KeyPackages are published by the invitee to their own outbox (see publishMarmotKeyPackage), so this only worked when the two happened to overlap. Union the invitee's NIP-65 outbox with ours before issuing fetchFirst(). 2. addMarmotGroupMember() routed the Welcome gift wrap through computeRelayListToBroadcast(). Its GiftWrap branch first tries the recipient's NIP-17 kind:10050 and falls back to computeRelayListForLinkedUser(), which reaches for inboxRelays()/relay hints and can legitimately return an empty set when the recipient has no cached NIP-17/NIP-65 data. client.publish then silently dropped the welcome, which is why the invitee saw nothing while the creator's local state looked healthy. Mirror sendNip04PrivateMessage's delivery strategy instead: publish to our own outbox (so we retain a copy and the invitee may find it via a shared relay) unioned with User.dmInboxRelays(), which already ladders NIP-17 kind:10050 → NIP-65 read relays — the exact set AccountGiftWrapsEoseManager listens on. A warning log covers the pathological case where both sides are empty. Note: spotlessApply/compile could not be run in the sandbox (Android Gradle plugin unreachable from this environment). Please run ./gradlew spotlessApply locally before merging. --- .../vitorpamplona/amethyst/model/Account.kt | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) 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 02d17cede..6aa8fddc7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1757,10 +1757,23 @@ class Account( // Build filter for the member's KeyPackages val filter = manager.subscriptionManager.keyPackageFilter(memberPubKey) - val relays = outboxRelays.flow.value - // Query across outbox relays - val filterMap = relays.associateWith { listOf(filter) } + // 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. + 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 = client.fetchFirst( @@ -1784,7 +1797,10 @@ class Account( kotlin.io.encoding.Base64 .decode(keyPackageBase64) 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( nostrGroupId = nostrGroupId, @@ -1823,9 +1839,32 @@ class Account( // Publish commit first (critical ordering) 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) { - 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) } }