From 118eff35c8714f3a7cd3163664f92998faf8bda9 Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Sun, 17 May 2026 10:04:00 -0400 Subject: [PATCH] fix(private-replies): timestamp range, filter bypass, fresh relay-list fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reasons a private reply could go missing, all addressed: - randomizeTimestamp now picks in the past up to 2 days, matching the NIP-59 recommendation. The earlier 1-day cap was a defensive workaround for clients with tight since-filters on kind-1059 subscriptions; that constraint no longer holds in practice. - NotificationRepository was silently dropping private-reply notifications when the sender was outside the recipient's web-of-trust or scored as spam by the classifier. Both filters now check eventRepo.isPrivateReply and skip — a gift-wrapped reply is explicit and addressed to the recipient, so spam/WoT gating is wrong here. - PrivateReplyPublisher only consulted cached kind 10002 if the recipient had no kind 10050; if neither was available we fell back to our own write relays, which the recipient never queries. Now fetches kind 10002 fresh from indexers via the new shared PeerRelayListLookup helper before falling back. DmConversationViewModel.fetchPeerRelayList delegates to the same helper. --- .../main/kotlin/com/wisp/app/nostr/Nip17.kt | 9 ++- .../wisp/app/repo/NotificationRepository.kt | 8 ++- .../com/wisp/app/repo/PeerRelayListLookup.kt | 55 +++++++++++++++++++ .../wisp/app/repo/PrivateReplyPublisher.kt | 5 ++ .../app/viewmodel/DmConversationViewModel.kt | 32 +---------- 5 files changed, 71 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/com/wisp/app/repo/PeerRelayListLookup.kt diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt index b22e281..48ab47a 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt @@ -408,10 +408,9 @@ object Nip17 { } private fun randomizeTimestamp(base: Long): Long { - // 0 to 1 day in the past — NIP-17 spec allows up to 2 days, but keeping it to 1 day - // ensures interop with clients (e.g. Amethyst) whose kind-1059 subscription may use - // a `since` filter tighter than 2 days, which would silently drop our gift wraps. - val oneDay = 24 * 60 * 60 - return base - random.nextInt(oneDay) + // 0 to 2 days in the past per NIP-59. Earlier 1-day cap was a workaround for clients + // whose kind-1059 subscriptions used a tight `since`; verified obsolete in 2026. + val twoDays = 2 * 24 * 60 * 60 + return base - random.nextInt(twoDays) } } diff --git a/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt index 74123ee..6495e3c 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt @@ -184,7 +184,9 @@ class NotificationRepository( val zapperPubkey = Nip57.getZapperPubkey(event) if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return } - if (safetyPrefs?.wotFilterEnabled?.value == true) { + // Private replies are explicit, gift-wrapped, and addressed to us — bypass WoT. + val isPrivateReply = eventRepo?.isPrivateReply(event.id) == true + if (!isPrivateReply && safetyPrefs?.wotFilterEnabled?.value == true) { val netRepo = extendedNetworkRepo if (netRepo != null && netRepo.isNetworkReady()) { val pubkeyToCheck = if (event.kind == 9735) { @@ -691,7 +693,9 @@ class NotificationRepository( } private fun mergeReply(event: NostrEvent, replyTarget: String, replyTargetHint: String?): Boolean { - if (safetyPrefs?.spamFilterEnabled?.value == true && + val isPrivateReply = eventRepo?.isPrivateReply(event.id) == true + if (!isPrivateReply && + safetyPrefs?.spamFilterEnabled?.value == true && contactRepo?.isFollowing(event.pubkey) != true && safetyPrefs?.isSpamSafelisted(event.pubkey) != true ) { diff --git a/app/src/main/kotlin/com/wisp/app/repo/PeerRelayListLookup.kt b/app/src/main/kotlin/com/wisp/app/repo/PeerRelayListLookup.kt new file mode 100644 index 0000000..6247667 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/PeerRelayListLookup.kt @@ -0,0 +1,55 @@ +package com.wisp.app.repo + +import com.wisp.app.nostr.ClientMessage +import com.wisp.app.nostr.Filter +import com.wisp.app.relay.RelayConfig +import com.wisp.app.relay.RelayEvent +import com.wisp.app.relay.RelayPool +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Fetch a peer's kind 10002 relay list (NIP-65) from indexer relays + the connected pool, + * then populate [RelayListRepository] with the freshest result. + * + * Shared by [com.wisp.app.viewmodel.DmConversationViewModel] (peer DM send) and + * [PrivateReplyPublisher] (private reply send) so both code paths fall back to a fresh + * relay-list fetch when the recipient hasn't published a kind 10050 DM relay set. + */ +object PeerRelayListLookup { + suspend fun fetch( + pubkey: String, + relayPool: RelayPool, + relayListRepo: RelayListRepository + ) { + val subId = "rl_${pubkey.take(8)}" + val filter = Filter( + kinds = listOf(10002), + authors = listOf(pubkey), + limit = 1 + ) + val reqMsg = ClientMessage.req(subId, filter) + for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { + relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true) + } + relayPool.sendToAll(reqMsg) + + val results = mutableListOf() + withTimeoutOrNull(4000L) { + relayPool.relayEvents + .filter { it.subscriptionId == subId } + .collect { results.add(it) } + } + + val closeMsg = ClientMessage.close(subId) + for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { + relayPool.sendToRelay(url, closeMsg) + } + relayPool.sendToAll(closeMsg) + + val best = results.maxByOrNull { it.event.created_at } + if (best != null) { + relayListRepo.updateFromEvent(best.event) + } + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt b/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt index f12a87d..bb6ffe0 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt @@ -75,6 +75,11 @@ object PrivateReplyPublisher { val recipientRelays: List = run { val dmRelays = DmRelayLookup.fetch(replyTo.pubkey, relayPool, dmRepo) if (dmRelays.isNotEmpty()) return@run dmRelays + // Cached kind 10002 may be missing or stale — fetch fresh from indexers before + // falling back to our own write relays (which the recipient never queries). + if (relayListRepo != null) { + PeerRelayListLookup.fetch(replyTo.pubkey, relayPool, relayListRepo) + } relayListRepo?.getReadRelays(replyTo.pubkey)?.takeIf { it.isNotEmpty() }?.let { return@run it } relayListRepo?.getWriteRelays(replyTo.pubkey)?.takeIf { it.isNotEmpty() }?.let { return@run it } emptyList() diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt index ee6c6e7..36592ee 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt @@ -570,37 +570,7 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) { */ private suspend fun fetchPeerRelayList(pubkeyHex: String, relayPool: RelayPool) { val repo = relayListRepo ?: return - - val subId = "rl_${pubkeyHex.take(8)}" - val filter = Filter( - kinds = listOf(10002), - authors = listOf(pubkeyHex), - limit = 1 - ) - val reqMsg = ClientMessage.req(subId, filter) - for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { - relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true) - } - relayPool.sendToAll(reqMsg) - - // Collect all responses within 4s; pick the freshest (highest created_at) - val results = mutableListOf() - withTimeoutOrNull(4000L) { - relayPool.relayEvents - .filter { it.subscriptionId == subId } - .collect { results.add(it) } - } - - val closeMsg = ClientMessage.close(subId) - for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { - relayPool.sendToRelay(url, closeMsg) - } - relayPool.sendToAll(closeMsg) - - val best = results.maxByOrNull { it.event.created_at } - if (best != null) { - repo.updateFromEvent(best.event) - } + com.wisp.app.repo.PeerRelayListLookup.fetch(pubkeyHex, relayPool, repo) } /**