fix(private-replies): timestamp range, filter bypass, fresh relay-list fallback

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.
This commit is contained in:
Barry Deen
2026-05-17 10:04:00 -04:00
parent 14a26662fb
commit 118eff35c8
5 changed files with 71 additions and 38 deletions
@@ -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)
}
}
@@ -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
) {
@@ -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<RelayEvent>()
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)
}
}
}
@@ -75,6 +75,11 @@ object PrivateReplyPublisher {
val recipientRelays: List<String> = 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()
@@ -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<RelayEvent>()
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)
}
/**