diff --git a/.env b/.env new file mode 100644 index 0000000..199b539 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SIGN_WITH=browser diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index e8a3443..a608898 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -969,7 +969,7 @@ fun WispNavHost( composable(Routes.DRAFTS) { LaunchedEffect(Unit) { - draftsViewModel.loadDrafts(feedViewModel.relayPool, activeSigner) + draftsViewModel.loadDrafts(feedViewModel.relayPool, activeSigner, feedViewModel.deletedEventsRepo) draftsViewModel.loadScheduledPosts(feedViewModel.relayPool, activeSigner) } val profileVersion by feedViewModel.eventRepo.profileVersion.collectAsState() @@ -1011,7 +1011,7 @@ fun WispNavHost( navController.navigate(Routes.COMPOSE) }, onDeleteDraft = { dTag -> - draftsViewModel.deleteDraft(dTag, feedViewModel.relayPool, activeSigner) + draftsViewModel.deleteDraft(dTag, feedViewModel.relayPool, activeSigner, feedViewModel.deletedEventsRepo) }, onDeleteScheduled = { eventId -> draftsViewModel.deleteScheduledPost(eventId, feedViewModel.relayPool, activeSigner) diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip09.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip09.kt index feccada..db81d89 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip09.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip09.kt @@ -12,11 +12,18 @@ object Nip09 { fun getDeletedEventIds(event: NostrEvent): List = event.tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] } + /** Extract deleted addressable coordinates ("a" tags) from a kind 5 deletion event. + * Each returned string is in the form "kind:pubkey:dTag". */ + fun getDeletedAddresses(event: NostrEvent): List = + event.tags.filter { it.size >= 2 && it[0] == "a" }.map { it[1] } + /** Build deletion tags for an addressable event (kinds 30000-39999) using an "a" tag. */ fun buildAddressableDeletionTags(kind: Int, pubkey: String, dTag: String): List> { return listOf( - listOf("a", "$kind:$pubkey:$dTag"), + listOf("a", addressCoord(kind, pubkey, dTag)), listOf("k", kind.toString()) ) } + + fun addressCoord(kind: Int, pubkey: String, dTag: String): String = "$kind:$pubkey:$dTag" } diff --git a/app/src/main/kotlin/com/wisp/app/repo/DeletedEventsRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/DeletedEventsRepository.kt index e67b7d2..5e418ee 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/DeletedEventsRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/DeletedEventsRepository.kt @@ -2,26 +2,52 @@ package com.wisp.app.repo import android.content.Context import android.content.SharedPreferences +import com.wisp.app.nostr.NostrEvent class DeletedEventsRepository(private val context: Context, pubkeyHex: String? = null) { private var prefs: SharedPreferences = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) private var deletedIds = HashSet() + private var deletedAddresses = HashSet() init { loadFromPrefs() } fun markDeleted(eventId: String) { - deletedIds.add(eventId) - saveToPrefs() + if (deletedIds.add(eventId)) saveIdsToPrefs() } fun isDeleted(eventId: String): Boolean = deletedIds.contains(eventId) + /** Mark an addressable event coordinate as deleted. Coord format: "kind:pubkey:dTag". */ + fun markDeletedAddress(coord: String) { + if (deletedAddresses.add(coord)) saveAddressesToPrefs() + } + + fun markDeletedAddress(kind: Int, pubkey: String, dTag: String) = + markDeletedAddress(addressCoord(kind, pubkey, dTag)) + + fun isAddressDeleted(coord: String): Boolean = deletedAddresses.contains(coord) + + fun isAddressDeleted(kind: Int, pubkey: String, dTag: String): Boolean = + deletedAddresses.contains(addressCoord(kind, pubkey, dTag)) + + /** Check whether an event should be treated as deleted. For addressable events (30000-39999), + * derive the coord from the event's own d-tag. */ + fun isEventDeleted(event: NostrEvent): Boolean { + if (deletedIds.contains(event.id)) return true + if (event.kind in 30000..39999) { + val dTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) ?: "" + return deletedAddresses.contains(addressCoord(event.kind, event.pubkey, dTag)) + } + return false + } + fun clear() { deletedIds = HashSet() + deletedAddresses = HashSet() prefs.edit().clear().apply() } @@ -31,20 +57,26 @@ class DeletedEventsRepository(private val context: Context, pubkeyHex: String? = loadFromPrefs() } - private fun saveToPrefs() { + private fun saveIdsToPrefs() { prefs.edit() .putStringSet("deleted_event_ids", deletedIds.toSet()) .apply() } + private fun saveAddressesToPrefs() { + prefs.edit() + .putStringSet("deleted_event_addresses", deletedAddresses.toSet()) + .apply() + } + private fun loadFromPrefs() { - val ids = prefs.getStringSet("deleted_event_ids", null) - if (ids != null) { - deletedIds = HashSet(ids) - } + prefs.getStringSet("deleted_event_ids", null)?.let { deletedIds = HashSet(it) } + prefs.getStringSet("deleted_event_addresses", null)?.let { deletedAddresses = HashSet(it) } } companion object { + fun addressCoord(kind: Int, pubkey: String, dTag: String): String = "$kind:$pubkey:$dTag" + private fun prefsName(pubkeyHex: String?): String = if (pubkeyHex != null) "wisp_deleted_events_$pubkeyHex" else "wisp_deleted_events" } diff --git a/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt index 9f75055..7849fdf 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt @@ -17,7 +17,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import com.wisp.app.db.EventPersistence @@ -44,6 +46,11 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: private val eventCache = ConcurrentHashMap() private val seenEventIds = ConcurrentHashMap.newKeySet() // thread-safe dedup that doesn't evict + /** Emits event IDs that were removed (e.g. via NIP-09 deletion). Screens like ThreadScreen + * observe this to prune their local state when a deletion arrives on any subscription. */ + private val _removedEvents = MutableSharedFlow(extraBufferCapacity = 64) + val removedEvents: SharedFlow = _removedEvents + // NIP-38: user status cache (pubkey -> status content + timestamp for dedup) private val userStatusCache = ConcurrentHashMap() private val userStatusTimestamps = ConcurrentHashMap() @@ -425,6 +432,14 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: removeEvent(id) } } + // NIP-09 also allows deleting addressable events via "a" tags. Only honor + // coords whose embedded pubkey matches the deletion event author. + for (coord in Nip09.getDeletedAddresses(event)) { + val parts = coord.split(":", limit = 3) + if (parts.size == 3 && parts[1] == event.pubkey) { + deletedEventsRepo?.markDeletedAddress(coord) + } + } } Nip88.KIND_POLL, Nip69.KIND_ZAP_POLL -> { // Polls are top-level feed events like kind 1 @@ -746,6 +761,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: } } feedDirty.trySend(Unit) + _removedEvents.tryEmit(eventId) } fun requestQuotedEvent(eventId: String, relayHints: List = emptyList()) { @@ -985,13 +1001,14 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: val queue = ArrayDeque() queue.add(rootId) visited.add(rootId) - eventCache[rootId]?.let { result.add(it) } + eventCache[rootId]?.let { if (deletedEventsRepo?.isDeleted(it.id) != true) result.add(it) } while (queue.isNotEmpty()) { val parentId = queue.removeFirst() val childIds = rootReplyIds[parentId] ?: continue for (id in childIds) { if (id in visited) continue visited.add(id) + if (deletedEventsRepo?.isDeleted(id) == true) continue eventCache[id]?.let { result.add(it) } queue.add(id) } diff --git a/app/src/main/kotlin/com/wisp/app/repo/InterestRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/InterestRepository.kt index 3cfa8d2..7ddf4b2 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/InterestRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/InterestRepository.kt @@ -11,7 +11,11 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -class InterestRepository(private val context: Context, pubkeyHex: String? = null) { +class InterestRepository( + private val context: Context, + private var pubkeyHex: String? = null, + private val deletedEventsRepo: DeletedEventsRepository? = null +) { private var prefs: SharedPreferences = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) private val json = Json { ignoreUnknownKeys = true } @@ -27,6 +31,10 @@ class InterestRepository(private val context: Context, pubkeyHex: String? = null fun updateFromEvent(event: NostrEvent) { val set = Nip51.parseInterestSet(event) ?: return + val pk = pubkeyHex + if (pk != null && deletedEventsRepo?.isAddressDeleted(Nip51.KIND_INTEREST_SET, pk, set.dTag) == true) { + return + } val existing = interestSets[set.dTag] if (existing != null && existing.createdAt >= set.createdAt) return interestSets[set.dTag] = set @@ -60,6 +68,7 @@ class InterestRepository(private val context: Context, pubkeyHex: String? = null fun reload(pubkeyHex: String?) { clear() + this.pubkeyHex = pubkeyHex prefs = context.getSharedPreferences(prefsName(pubkeyHex), Context.MODE_PRIVATE) loadFromPrefs() } @@ -81,10 +90,17 @@ class InterestRepository(private val context: Context, pubkeyHex: String? = null val str = prefs.getString("interest_sets", null) ?: return try { val serializable = json.decodeFromString>(str) + val pk = pubkeyHex + var dropped = false for (s in serializable) { + if (pk != null && deletedEventsRepo?.isAddressDeleted(Nip51.KIND_INTEREST_SET, pk, s.dTag) == true) { + dropped = true + continue + } interestSets[s.dTag] = InterestSet(s.dTag, s.name, s.hashtags.toSet(), s.createdAt) } _sets.value = interestSets.values.sortedBy { it.name } + if (dropped) saveToPrefs(_sets.value) } catch (_: Exception) {} } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/DraftsViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/DraftsViewModel.kt index 3e1fce2..8d8b509 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/DraftsViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/DraftsViewModel.kt @@ -10,6 +10,7 @@ import com.wisp.app.nostr.Nip37 import com.wisp.app.nostr.NostrEvent import com.wisp.app.nostr.NostrSigner import com.wisp.app.relay.RelayPool +import com.wisp.app.repo.DeletedEventsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -38,7 +39,7 @@ class DraftsViewModel(app: Application) : AndroidViewModel(app) { val SCHEDULER_RELAY = "wss://scheduler.nostrarchives.com" } - fun loadDrafts(relayPool: RelayPool, signer: NostrSigner?) { + fun loadDrafts(relayPool: RelayPool, signer: NostrSigner?, deletedEventsRepo: DeletedEventsRepository? = null) { if (draftsLoaded || signer == null) return draftsLoaded = true @@ -54,10 +55,16 @@ class DraftsViewModel(app: Application) : AndroidViewModel(app) { if (event.kind != Nip37.KIND_DRAFT) return@collect if (event.pubkey != signer.pubkeyHex) return@collect + val dTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + if (dTag != null && + deletedEventsRepo?.isAddressDeleted(Nip37.KIND_DRAFT, signer.pubkeyHex, dTag) == true) { + _drafts.value = _drafts.value.filter { it.dTag != dTag } + return@collect + } + try { val decrypted = signer.nip44Decrypt(event.content, signer.pubkeyHex) if (decrypted.isBlank()) { - val dTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) if (dTag != null) { _drafts.value = _drafts.value.filter { it.dTag != dTag } } @@ -149,20 +156,35 @@ class DraftsViewModel(app: Application) : AndroidViewModel(app) { } } - fun deleteDraft(dTag: String, relayPool: RelayPool, signer: NostrSigner?) { + fun deleteDraft( + dTag: String, + relayPool: RelayPool, + signer: NostrSigner?, + deletedEventsRepo: DeletedEventsRepository? = null + ) { if (signer == null) return removeDraftLocally(dTag) + // Persist locally first so any future relay response for this coord is silently dropped, + // even before the kind 5 deletion propagates. + deletedEventsRepo?.markDeletedAddress(Nip37.KIND_DRAFT, signer.pubkeyHex, dTag) viewModelScope.launch(Dispatchers.Default) { try { - val tags = Nip37.buildDraftTags(dTag, 1) + // NIP-09: publish an addressable-delete (kind 5 with "a" tag) so relays drop the draft. + val deletionTags = Nip09.buildAddressableDeletionTags(Nip37.KIND_DRAFT, signer.pubkeyHex, dTag) + val deleteEvent = signer.signEvent(kind = 5, content = "", tags = deletionTags) + relayPool.sendToWriteRelays(ClientMessage.event(deleteEvent)) + + // Also publish an empty-content replacement so clients that don't honor NIP-09 + // (or that use the replace-by-address semantics of NIP-37) still observe deletion. + val replacementTags = Nip37.buildDraftTags(dTag, 1) val encrypted = signer.nip44Encrypt("", signer.pubkeyHex) - val event = signer.signEvent( + val replacement = signer.signEvent( kind = Nip37.KIND_DRAFT, content = encrypted, - tags = tags + tags = replacementTags ) - relayPool.sendToWriteRelays(ClientMessage.event(event)) + relayPool.sendToWriteRelays(ClientMessage.event(replacement)) } catch (_: Exception) { // Best effort } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index 59af89f..ae6041a 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -194,7 +194,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val relaySetRepo = RelaySetRepository(app, pubkeyHex) val pinRepo = PinRepository(app, pubkeyHex) val blossomRepo = BlossomRepository(app, pubkeyHex) - val interestRepo = InterestRepository(app, pubkeyHex) + val interestRepo = InterestRepository(app, pubkeyHex, deletedEventsRepo) val relayInfoRepo = RelayInfoRepository() val relayScoreBoard = RelayScoreBoard(app, relayListRepo, contactRepo, pubkeyHex) val outboxRouter = OutboxRouter(relayPool, relayListRepo, relayHintStore, relayScoreBoard) @@ -317,7 +317,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val startup: StartupCoordinator = StartupCoordinator( relayPool, outboxRouter, subManager, eventRepo, eventPersistence, contactRepo, muteRepo, notifRepo, - listRepo, bookmarkRepo, bookmarkSetRepo, relaySetRepo, pinRepo, blossomRepo, interestRepo, customEmojiRepo, + listRepo, bookmarkRepo, bookmarkSetRepo, relaySetRepo, pinRepo, blossomRepo, deletedEventsRepo, interestRepo, customEmojiRepo, relayListRepo, relayScoreBoard, relayHintStore, healthTracker, keyRepo, extendedNetworkRepo, metadataFetcher, profileRepo, relayInfoRepo, nip05Repo, nwcRepo, sparkRepo, walletModeRepo, dmRepo, liveStreamRepo, zapPrefs, lifecycleManager, eventRouter, feedSub, diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ListCrudManager.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ListCrudManager.kt index db3fb6c..7c69833 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ListCrudManager.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ListCrudManager.kt @@ -326,6 +326,8 @@ class ListCrudManager( fun deleteInterestSet(dTag: String) { val s = getSigner() ?: return val myPubkey = s.pubkeyHex + // Persist the deletion locally so any future event for this coord returned by a relay is silently dropped. + eventRepo.deletedEventsRepo?.markDeletedAddress(Nip51.KIND_INTEREST_SET, myPubkey, dTag) val deletionTags = Nip09.buildAddressableDeletionTags(Nip51.KIND_INTEREST_SET, myPubkey, dTag) scope.launch { val deleteEvent = s.signEvent(kind = 5, content = "", tags = deletionTags) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt index 74cbadc..642c347 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt @@ -19,6 +19,7 @@ import com.wisp.app.repo.BookmarkRepository import com.wisp.app.repo.BookmarkSetRepository import com.wisp.app.repo.ContactRepository import com.wisp.app.repo.CustomEmojiRepository +import com.wisp.app.repo.DeletedEventsRepository import com.wisp.app.repo.DiscoveryState import com.wisp.app.repo.DmRepository import com.wisp.app.repo.EventRepository @@ -75,6 +76,7 @@ class StartupCoordinator( private val relaySetRepo: RelaySetRepository, private val pinRepo: PinRepository, private val blossomRepo: BlossomRepository, + private val deletedEventsRepo: DeletedEventsRepository, private val interestRepo: InterestRepository, private val customEmojiRepo: CustomEmojiRepository, private val relayListRepo: RelayListRepository, @@ -183,6 +185,7 @@ class StartupCoordinator( pinRepo.reload(newPubkey) listRepo.reload(newPubkey) blossomRepo.reload(newPubkey) + deletedEventsRepo.reload(newPubkey) interestRepo.reload(newPubkey) nwcRepo.reload(newPubkey) sparkRepo.reload(newPubkey) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt index 7d3f6ef..95b7cc7 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ThreadViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wisp.app.nostr.ClientMessage import com.wisp.app.nostr.Filter +import com.wisp.app.nostr.Nip09 import com.wisp.app.nostr.Nip10 import com.wisp.app.nostr.NostrEvent import com.wisp.app.relay.OutboxRouter @@ -71,6 +72,7 @@ class ThreadViewModel : ViewModel() { private var rebuildJob: Job? = null private var metadataBatchJob: Job? = null private var muteObserverJob: Job? = null + private var deletionObserverJob: Job? = null // Incremental metadata tracking private val metadataSubscribedIds = mutableSetOf() @@ -147,7 +149,7 @@ class ThreadViewModel : ViewModel() { rootId = eventId } - // Seed from cache: BFS walks nested replies + // Seed from cache: BFS walks nested replies (getCachedThreadEvents already filters deletions) val cachedEvents = eventRepo.getCachedThreadEvents(rootId) for (event in cachedEvents) { threadEvents[event.id] = event @@ -158,12 +160,37 @@ class ThreadViewModel : ViewModel() { _isLoading.value = false } + // Prune thread state when any event is removed (e.g. NIP-09 deletion processed by EventRepository + // via the feed sub, thread-reactions sub, or anywhere else). + deletionObserverJob?.cancel() + deletionObserverJob = viewModelScope.launch { + eventRepo.removedEvents.collect { removedId -> + if (threadEvents.remove(removedId) != null) { + synchronized(pendingMetadataIds) { pendingMetadataIds.remove(removedId) } + metadataSubscribedIds.remove(removedId) + if (removedId == rootId) _rootEvent.value = null + scheduleRebuild() + } + } + } + // Direct RelayPool collection — no dependency on FeedViewModel collectorJob = viewModelScope.launch { relayPool.relayEvents.collect { (event, relayUrl, subscriptionId) -> if (subscriptionId != "thread-root" && subscriptionId != "thread-replies") return@collect + + // Route kind 5 deletions through EventRepository so they mark the target deleted + // and fire removedEvents; the observer above will prune threadEvents. + if (event.kind == 5) { + eventRepo.addEvent(event) + return@collect + } + if (event.kind != 1) return@collect + // Silently drop events the user has already deleted on some other client/session. + if (eventRepo.deletedEventsRepo?.isDeleted(event.id) == true) return@collect + eventRepo.cacheEvent(event) eventRepo.addEventRelay(event.id, relayUrl) @@ -194,12 +221,12 @@ class ThreadViewModel : ViewModel() { } } - // Also collect thread reactions/engagement + // Also collect thread reactions/engagement + deletions for replies viewModelScope.launch { relayPool.relayEvents.collect { (event, _, subscriptionId) -> if (!subscriptionId.startsWith("thread-reactions")) return@collect when (event.kind) { - 7, 6, 1018 -> eventRepo.addEvent(event) + 5, 7, 6, 1018 -> eventRepo.addEvent(event) 9735 -> { eventRepo.addEvent(event) val zapperPubkey = com.wisp.app.nostr.Nip57.getZapperPubkey(event) @@ -222,7 +249,8 @@ class ThreadViewModel : ViewModel() { // Phase 2: Now we (hopefully) have the root — use outbox routing for replies val rootEvent = _rootEvent.value - val repliesFilter = Filter(kinds = listOf(1), eTags = listOf(rootId)) + // Include kind 5 so deletions of the root (or any event tagging the root) come through. + val repliesFilter = Filter(kinds = listOf(1, 5), eTags = listOf(rootId)) if (rootEvent != null) { outboxRouter.subscribeToUserReadRelays( "thread-replies", rootEvent.pubkey, repliesFilter @@ -317,7 +345,8 @@ class ThreadViewModel : ViewModel() { // Phase 1: Root note engagement (high priority) — await EOSE for reliable counts val rootSubId = "thread-reactions" activeMetadataSubs.add(rootSubId) - val rootFilter = Filter(kinds = listOf(7, 6, 1018, 9735), eTags = listOf(rootId)) + // Include kind 5 so reply deletions (which reference the reply id, not the root) also arrive here. + val rootFilter = Filter(kinds = listOf(5, 7, 6, 1018, 9735), eTags = listOf(rootId)) sendToEngagementRelays(relayPool, rootSubId, rootFilter, rootAuthorPubkey) subManager.awaitEoseWithTimeout(rootSubId, 3_500) @@ -327,7 +356,7 @@ class ThreadViewModel : ViewModel() { replyIds.chunked(50).forEachIndexed { index, batch -> val subId = "thread-reactions-${index + 1}" activeMetadataSubs.add(subId) - val filter = Filter(kinds = listOf(7, 6, 1018, 9735), eTags = batch) + val filter = Filter(kinds = listOf(5, 7, 6, 1018, 9735), eTags = batch) sendToEngagementRelays(relayPool, subId, filter, rootAuthorPubkey) } } @@ -353,7 +382,7 @@ class ThreadViewModel : ViewModel() { metadataBatchIndex++ val subId = "thread-reactions-b$metadataBatchIndex" activeMetadataSubs.add(subId) - val filter = Filter(kinds = listOf(7, 6, 1018, 9735), eTags = batch) + val filter = Filter(kinds = listOf(5, 7, 6, 1018, 9735), eTags = batch) sendToEngagementRelays(relayPool, subId, filter, _rootEvent.value?.pubkey) } } @@ -384,6 +413,7 @@ class ThreadViewModel : ViewModel() { rebuildJob?.cancel() metadataBatchJob?.cancel() muteObserverJob?.cancel() + deletionObserverJob?.cancel() relayPoolRef?.let { pool -> pool.closeOnAllRelays("thread-root") pool.closeOnAllRelays("thread-replies") @@ -398,8 +428,10 @@ class ThreadViewModel : ViewModel() { val spamEvents = mutableListOf() val pubkeysToScore = mutableSetOf() + val deletedRepo = eventRepoRef?.deletedEventsRepo for (event in threadEvents.values) { if (event.id == rootId) continue + if (deletedRepo?.isDeleted(event.id) == true) continue if (muteRepo?.isBlocked(event.pubkey) == true) continue if (Nip10.isStandaloneQuote(event)) continue diff --git a/zapstore.yaml b/zapstore.yaml new file mode 100644 index 0000000..524f1c4 --- /dev/null +++ b/zapstore.yaml @@ -0,0 +1,26 @@ +repository: https://github.com/barrydeen/wisp +description: A lightning fast, feature rich and highly polished nostr client. Watch live streams, join chat rooms, view trending and hashtags feeds and enjoy state of the art nostr searh and AI powered spam detection. +summary: The Client that Just Works +tags: + - nostr + - client + - nip29 + - groups + - chat + - hashtag + - streams + - nip17 + - dms +license: MIT +website: https://wisp.talk +images: + - https://miro.medium.com/v2/resize:fit:1400/format:webp/1*NYKzYicGd5jRKt5AA1oh8A.jpeg +supported_nips: + - "01" + - "29" + - "17" + - "20" + - "21" + - "22" +metadata_sources: + - github