diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 820cbcaa1a..bdad25dece 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter @@ -114,6 +115,7 @@ import com.vitorpamplona.quartz.buzz.stream.StreamMessageScheduledEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.buzz.stream.StreamReminderEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessagePayload import com.vitorpamplona.quartz.buzz.stream.sidecars.ChannelSummaryEvent import com.vitorpamplona.quartz.buzz.stream.sidecars.PresenceSnapshotEvent import com.vitorpamplona.quartz.buzz.teams.TeamEvent @@ -2223,6 +2225,30 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { attachToRelayGroupIfScoped(event, relay) } + /** + * A Buzz kind-40099 system message. It renders as a narration row in the channel feed (via + * [consumeBuzzTimelineEvent]), but a `channel_deleted` one is also the **authoritative signal that + * a channel is gone**: the relay soft-deletes the channel and its 39000/39001/39002 discovery + * events but emits no member-removed notification and never retracts the kind-44100 that seeds the + * browse list — so without this the deleted channel keeps re-appearing (a stale 44100 re-announced + * every restart, its metadata now blank so it shows optimistically). Recording the delete in + * [RelayGroupDeletions] filters it out of every list and persists it across restarts. + * + * Gated on [isRelaySignedGroupEvent] (the 40099 is signed by the relay keypair) so a spoofed + * system message from a stray author can't hide a channel. Cross-device by construction: the relay + * replays this on subscribe, so a channel deleted on Buzz web/desktop is honored here too. + */ + private fun consume( + event: SystemMessageEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean = + consumeBuzzTimelineEvent(event, relay, wasVerified).also { + if (relay != null && isRelaySignedGroupEvent(event, relay) && event.payload()?.type == SystemMessagePayload.CHANNEL_DELETED) { + event.channel()?.let { channelId -> RelayGroupDeletions.markDeleted(GroupId(channelId, relay)) } + } + } + /** Store-only consume for Buzz kinds that carry no channel timeline row. */ private fun consumeBuzzRegularEvent( event: Event, @@ -4798,7 +4824,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is StreamMessageV2Event -> consumeBuzzTimelineEvent(event, relay, wasVerified) is StreamMessageEditEvent -> consume(event, relay, wasVerified) is StreamMessageDiffEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) - is SystemMessageEvent -> consumeBuzzTimelineEvent(event, relay, wasVerified) + is SystemMessageEvent -> consume(event, relay, wasVerified) is CanvasEvent -> consume(event, relay, wasVerified) // Forum root (45001) is a thread, not a chat row → Threads collection. Comments (45003) // and votes (45002) are store-only: the forum-thread detail loads them on demand by root. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 92027d1cd2..30c5f73e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -23,11 +23,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RELAY_GROUP_METADATA_KINDS import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent +import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -154,21 +156,36 @@ class BuzzRelayImportViewModel : ViewModel() { false } - // 2. Fetch each channel's NIP-29 metadata (39000-39003) so its name + Buzz `t` type load. + // 2. Fetch each channel's NIP-29 metadata (39000-39003, `#d`-scoped) so its name + Buzz + // `t` type load, AND the relay's kind-40099 system messages (`#h`-scoped) so a + // `channel_deleted` one is seen. The relay soft-deletes a deleted channel's 39000 + // and never retracts the kind-44100 that seeded `channelIds`, so without pulling the + // 40099 a deleted channel — its metadata now blank — would show optimistically here + // forever. LocalCache records the delete into RelayGroupDeletions on consume. if (channelIds.isNotEmpty()) { account.client.fetchAllWithHooks( - filters = mapOf(relay to listOf(Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())))), + filters = + mapOf( + relay to + listOf( + Filter(kinds = RELAY_GROUP_METADATA_KINDS, tags = mapOf("d" to channelIds.toList())), + Filter(kinds = listOf(SystemMessageEvent.KIND), tags = mapOf("h" to channelIds.toList())), + ), + ), timeoutMs = 8_000, pendingOnAuthRequired = true, ) { _, _ -> false } } // 3. Keep only non-DM workspace channels; a channel whose metadata hasn't arrived - // (type unknown) is optimistically shown as a workspace channel. + // (type unknown) is optimistically shown as a workspace channel. Deleted channels + // (kind-40099 `channel_deleted`, recorded above) are dropped — the relay keeps + // re-announcing their kind-44100, so this is the only place they leave the list. _channels.value = channelIds .mapNotNull { id -> val groupId = GroupId(id, relay) + if (RelayGroupDeletions.isDeleted(groupId)) return@mapNotNull null val channel = LocalCache.getOrCreateRelayGroupChannel(groupId) if (channel.event?.isBuzzDm() == true) null else groupId }.sortedBy { it.id }