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) } /**