fix(buzz): honor the relay's channel_deleted signal so deleted channels leave the list

The first pass only recorded a deletion when THIS device published the 9008, so a
channel deleted on another Buzz client — or before the fix existed — kept showing
and survived restarts. The Buzz relay's handle_delete_group 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; its
only cross-device signal is the relay-signed kind-40099 system message with
type=channel_deleted (verified against block/buzz side_effects.rs).

Consume that signal: LocalCache records a relay-signed 40099 channel_deleted into
RelayGroupDeletions (gated on isRelaySignedGroupEvent so a spoofed one can't hide a
channel), and BuzzRelayImportViewModel.discover now also fetches the #h-scoped
40099 for its candidate channels and drops deleted ones — the relay keeps
re-announcing their 44100 with blank metadata, so this is the only place they
leave the list. Cross-device and retroactive: the relay replays the 40099 on
subscribe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MNVEKhaAu4vQRZnXv3rfG
This commit is contained in:
Claude
2026-07-29 13:25:37 +00:00
parent 646459e367
commit 9a5b36d0ba
2 changed files with 47 additions and 4 deletions
@@ -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.
@@ -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 }