From 646459e367273f5bed3c36f9fc7f553fbd89ab65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 05:44:28 +0000 Subject: [PATCH] fix(buzz): remove a deleted channel from the community channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a channel (kind-9008) published the delete and dropped it from the user's kind-10009 list, but the community's browse list is built from the cached kind-39000 metadata and the Buzz membership (kind-44100) set — neither of which the delete touched — so the channel lingered in the list, and a stale 44100 re-announcement could bring it back after a restart. Track deleted relay-group channels in a device-global RelayGroupDeletions registry (keyed by GroupId.toKey, so it stays relay-scoped), persist it via RelayGroupDeletionPreferences, mark the channel on deleteRelayGroup, and filter deleted keys out of both the directory channels and the Buzz membership list in RelayGroupChannelListScreen. The delete now removes the row live and it stays gone across restarts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG --- .../com/vitorpamplona/amethyst/AppModules.kt | 6 ++ .../vitorpamplona/amethyst/model/Account.kt | 5 ++ .../RelayGroupDeletionPreferences.kt | 77 ++++++++++++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 42 +++++---- .../nip29RelayGroups/RelayGroupDeletions.kt | 75 ++++++++++++++++ .../RelayGroupDeletionsTest.kt | 87 +++++++++++++++++++ 6 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f903682bfc..9f44436f55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.model.preferences.RelayGroupDeletionPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder @@ -287,6 +288,11 @@ class AppModules( // Restore + persist the user's starred Buzz workspace channels across restarts (device-global). val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope) + // Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a + // deleted channel stays hidden across a restart even if the host relay re-announces a stale + // kind-44100 for it (device-global; a delete is authoritative and terminal for everyone). + val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope) + // Service that will run at all times to receive events from Pokey val pokeyReceiver = PokeyReceiver() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6c7b0597de..91f929fc61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership @@ -3465,6 +3466,10 @@ class Account( val template = DeleteGroupEvent.build(channel.groupId.id) signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } unfollow(channel) + // Remember the deletion so the channel leaves the community's browse list immediately and + // stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a + // stale re-announced 44100 on a Buzz relay) would otherwise keep it visible. + RelayGroupDeletions.markDeleted(channel.groupId) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt new file mode 100644 index 0000000000..99e1f99c01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/RelayGroupDeletionPreferences.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Device-global persistence for the set of deleted NIP-29 relay-group channels ([RelayGroupDeletions]), + * so a channel the user deleted (kind-9008) stays gone across a restart — even if the host relay keeps + * re-announcing a stale kind-44100 for it. Mirrors [BuzzChannelStarPreferences]: app-wide (not + * per-account), loads the saved keys into the singleton on construction, then writes every later change + * back. Construct once, eagerly. + */ +@Stable +class RelayGroupDeletionPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + init { + scope.launch { + restoreFromDisk() + // drop(1) skips the value present at collection start, which restoreFromDisk already wrote. + RelayGroupDeletions.flow.drop(1).collect { persist(it) } + } + } + + private suspend fun restoreFromDisk() { + try { + val raw = context.sharedPreferencesDataStore.data.first()[KEY] ?: return + if (raw.isNotEmpty()) RelayGroupDeletions.restore(raw) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error reading deleted channels: ${e.message}" } + } + } + + private suspend fun persist(keys: Set) { + try { + context.sharedPreferencesDataStore.edit { prefs -> prefs[KEY] = keys } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("RelayGroupDeletionPrefs") { "Error writing deleted channels: ${e.message}" } + } + } + + companion object { + private val KEY = stringSetPreferencesKey("nip29.deletedChannels") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index c4d62d47f9..aa20a8aea0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache @@ -172,6 +173,11 @@ fun RelayGroupChannelListScreen( } } + // Channels this device has deleted (kind-9008). A delete is terminal — the relay drops the group — + // but our cached 39000 (and a Buzz relay's stale re-announced 44100) would keep it in the list, so + // filter them out everywhere below. Collected as a StateFlow so a delete removes the row live. + val deletedChannels by RelayGroupDeletions.flow.collectAsStateWithLifecycle() + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while @@ -179,20 +185,22 @@ fun RelayGroupChannelListScreen( // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 // (a different author) stays filtered. val channels = - remember(allChannels, relayInfo) { + remember(allChannels, relayInfo, deletedChannels) { val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null - if (nip11Known) { - allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } - } else { - val dominantSigner = - allChannels - .mapNotNull { it.event?.pubKey } - .groupingBy { it } - .eachCount() - .maxByOrNull { it.value } - ?.key - if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels - } + val signed = + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + signed.filterNot { it.groupId.toKey() in deletedChannels } } // Buzz relays expose no public group directory (membership is server-side), so `channels` above @@ -226,9 +234,13 @@ fun RelayGroupChannelListScreen( // ids so nothing the old flat list showed disappears. val channelsById = remember(allChannels) { allChannels.associateBy { it.groupId.id } } val buzzGroupIds = - remember(buzzChannels, channels) { + remember(buzzChannels, channels, deletedChannels) { val seen = LinkedHashSet() - (buzzChannels + channels.map { it.groupId }).filter { seen.add(it.id) } + // `channels` is already delete-filtered; also drop deleted ids from the membership-scoped + // `buzzChannels` (kind-44100), which the relay can keep re-announcing after a delete. + (buzzChannels + channels.map { it.groupId }) + .filterNot { it.toKey() in deletedChannels } + .filter { seen.add(it.id) } } fun buzzTypeOf(groupId: GroupId): String? = channelsById[groupId.id]?.event?.buzzChannelType() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt new file mode 100644 index 0000000000..fc3df75e65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletions.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The set of NIP-29 relay-group channels this device has **deleted** (kind-9008), keyed by + * [GroupId.toKey] (`id@relay`, so a group is scoped to its host relay — group ids are only + * unique per relay). + * + * A delete is terminal and destroys the group for everyone: the relay drops it and stops serving + * its kind-39000 metadata. But the client already holds that metadata in `LocalCache`, and a Buzz + * relay may keep re-announcing a stale kind-44100 member-added notification that would re-surface + * the channel in the community's browse list — after a restart too, since it's re-fetched from the + * relay. So the deletion has to be remembered client-side and the channel filtered out everywhere + * the list is built. + * + * Like [BuzzChannelStars], there is no personal Nostr event for "I deleted this from my view", so + * this is a process-wide singleton mirrored to a device-global store by the platform + * ([com.vitorpamplona.amethyst] `RelayGroupDeletionPreferences`) and restored at startup. Deleting + * is authoritative and terminal, so an entry is only ever added, never removed. + */ +object RelayGroupDeletions { + private val deleted = MutableStateFlow>(emptySet()) + + /** The deleted group keys ([GroupId.toKey]); the community view collects this to hide them. */ + val flow: StateFlow> = deleted + + fun isDeleted(groupKey: String): Boolean = groupKey in deleted.value + + fun isDeleted(groupId: GroupId): Boolean = isDeleted(groupId.toKey()) + + /** Record [groupId] as deleted (idempotent). */ + fun markDeleted(groupId: GroupId) = markDeleted(groupId.toKey()) + + /** Record [groupKey] ([GroupId.toKey]) as deleted (idempotent). */ + fun markDeleted(groupKey: String) { + while (true) { + val current = deleted.value + if (groupKey in current) return + if (deleted.compareAndSet(current, current + groupKey)) return + } + } + + /** Replaces the whole set — used to restore from disk at startup. */ + fun restore(keys: Set) { + deleted.value = keys + } + + /** Test-only: clears the set so unit tests don't leak state into each other. */ + fun clearForTesting() { + deleted.value = emptySet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt new file mode 100644 index 0000000000..6a60adc6e1 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupDeletionsTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The device-global "deleted channels" bookkeeping: a delete is relay-scoped (keyed by + * [GroupId.toKey]) and terminal (only ever added), so the same id on a different relay stays visible. + */ +class RelayGroupDeletionsTest { + private val relayA = RelayUrlNormalizer.normalize("wss://a.example.com") + private val relayB = RelayUrlNormalizer.normalize("wss://b.example.com") + private val gid = "0123456789abcdef" + + @BeforeTest + fun reset() = RelayGroupDeletions.clearForTesting() + + @AfterTest + fun tearDown() = RelayGroupDeletions.clearForTesting() + + @Test + fun marksAChannelDeletedAndReflectsInTheFlow() { + val group = GroupId(gid, relayA) + assertFalse(RelayGroupDeletions.isDeleted(group)) + + RelayGroupDeletions.markDeleted(group) + + assertTrue(RelayGroupDeletions.isDeleted(group)) + assertTrue(RelayGroupDeletions.isDeleted(group.toKey())) + assertEquals(setOf(group.toKey()), RelayGroupDeletions.flow.value) + } + + @Test + fun deletionIsRelayScoped() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + // The same group id on a different host relay is a different group, so it stays visible. + assertTrue(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayB))) + } + + @Test + fun markingIsIdempotent() { + val group = GroupId(gid, relayA) + RelayGroupDeletions.markDeleted(group) + RelayGroupDeletions.markDeleted(group) + + assertEquals(1, RelayGroupDeletions.flow.value.size) + } + + @Test + fun restoreReplacesTheWholeSet() { + RelayGroupDeletions.markDeleted(GroupId(gid, relayA)) + + val restored = setOf(GroupId("aaaa", relayB).toKey(), GroupId("bbbb", relayB).toKey()) + RelayGroupDeletions.restore(restored) + + assertEquals(restored, RelayGroupDeletions.flow.value) + assertFalse(RelayGroupDeletions.isDeleted(GroupId(gid, relayA))) + } +}