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:
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user