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))) + } +}