Merge pull request #2653 from vitorpamplona/claude/filter-empty-rooms-jRDGd
Improve Nests feed filtering to check for active speakers
This commit is contained in:
@@ -1549,30 +1549,47 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio-room presence (kind-10312) — addressable storage AND
|
||||
* attach the version note to the room's [LiveActivitiesChannel].
|
||||
* The home live-bubble surfaces a room when a follow is
|
||||
* publishing in it; that fan-out walks `channel.notes`, so the
|
||||
* presence event needs to be in there alongside chat. Without
|
||||
* this, presence-driven inclusion can't see follows broadcasting
|
||||
* in the room (only chat-driven inclusion would fire).
|
||||
* Audio-room presence (kind-10312) — addressable storage plus an
|
||||
* author-keyed entry in the room's
|
||||
* [LiveActivitiesChannel.presenceNotes] index.
|
||||
*
|
||||
* Presence is intentionally NOT added to `channel.notes`: that
|
||||
* map is dominated by chat in active rooms and feeds that care
|
||||
* about presence (Nests drawer, home live-bubble, NestsFeedLoaded)
|
||||
* iterate `presenceNotes` directly for an O(speakers) scan.
|
||||
*
|
||||
* Cross-room move handling: kind-10312 is replaceable per author,
|
||||
* but the room a presence points to (`a`-tag) can change when a
|
||||
* speaker hops between rooms. The replaceable cache only swaps
|
||||
* the addressable's content — it has no notion of which channel
|
||||
* the old version was attached to. Without explicit eviction the
|
||||
* old room would keep surfacing as "live" via the stale entry
|
||||
* until it dropped out of the freshness window. Capture the prior
|
||||
* room before replacement and, when it differs, drop the author
|
||||
* from the old channel's presence index.
|
||||
*/
|
||||
fun consume(
|
||||
event: MeetingRoomPresenceEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val priorVersion = getAddressableNoteIfExists(event.address())?.event as? MeetingRoomPresenceEvent
|
||||
val priorRoomAddress = priorVersion?.interactiveRoom()?.address
|
||||
val isReplacement = priorVersion != null && event.createdAt > priorVersion.createdAt
|
||||
|
||||
val new = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
// The replaceable cache keys this on the AUTHOR's address
|
||||
// (kind=10312, pubkey, fixed-d-tag) — independent of the
|
||||
// room. To wire the room bubble we also attach the version
|
||||
// note to the room's channel keyed by its kind-30312 address.
|
||||
val roomAddress = event.interactiveRoom()?.address ?: return new
|
||||
if (roomAddress.kind != MeetingSpaceEvent.KIND) return new
|
||||
|
||||
if (isReplacement && priorRoomAddress != null && priorRoomAddress != roomAddress) {
|
||||
getLiveActivityChannelIfExists(priorRoomAddress)?.removePresenceNote(event.pubKey)
|
||||
}
|
||||
|
||||
val channel = getOrCreateLiveChannel(roomAddress)
|
||||
val versionNote = getOrCreateNote(event.id)
|
||||
channel.addNote(versionNote, relay)
|
||||
channel.addPresenceNote(versionNote)
|
||||
if (relay != null) channel.addRelay(relay)
|
||||
|
||||
return new
|
||||
}
|
||||
@@ -2224,6 +2241,11 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
// `NestsFeedFilter` so a presence still inside any feed's window
|
||||
// can never be pruned.
|
||||
private val PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L
|
||||
|
||||
fun pruneOldMessagesChannel(channel: Channel) {
|
||||
val toBeRemoved = channel.pruneOldMessages()
|
||||
|
||||
@@ -2237,6 +2259,14 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
// Audio-room presence is keyed separately from `notes` and
|
||||
// never gets reaped by the top-N rule. Drop entries older
|
||||
// than 2× the 10-min freshness window so the index doesn't
|
||||
// grow unbounded with every author who ever heartbeat here.
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS)
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
|
||||
+2
-16
@@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||
|
||||
class ChannelFeedFilter(
|
||||
val channel: Channel,
|
||||
@@ -38,25 +37,12 @@ class ChannelFeedFilter(
|
||||
override fun changesFlow() = channel.changesFlow()
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { _, it -> isChatEvent(it) && account.isAcceptable(it) })
|
||||
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) })
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> =
|
||||
newItems
|
||||
.filter { channel.notes.containsKey(it.idHex) && isChatEvent(it) && account.isAcceptable(it) }
|
||||
.filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
|
||||
/**
|
||||
* Reject non-chat events that get attached to a [Channel] for other
|
||||
* surfaces. The current case: kind-10312
|
||||
* [MeetingRoomPresenceEvent]s land in `channel.notes` so the home
|
||||
* live-bubble can detect a follow broadcasting in a Nest
|
||||
* (HomeLiveFilter scans channel.notes); they have no chat content
|
||||
* and would otherwise render as an empty card in the chat panel.
|
||||
*
|
||||
* Anything else is passed through — chat messages, zaps, raids,
|
||||
* clips, channel-create / metadata events all belong here.
|
||||
*/
|
||||
private fun isChatEvent(note: Note): Boolean = note.event !is MeetingRoomPresenceEvent
|
||||
}
|
||||
|
||||
+22
@@ -103,6 +103,16 @@ class HomeLiveFilter(
|
||||
return true
|
||||
}
|
||||
|
||||
// Audio-room presence (kind-10312) lives in `presenceNotes`,
|
||||
// not `notes`. Check it separately so a follow broadcasting in
|
||||
// a Nest still surfaces the bubble even if no chat happened.
|
||||
val hasPresence =
|
||||
channel.presenceNotes
|
||||
.filter { _, note ->
|
||||
acceptableChatEvent(note, filterParams, timeLimit)
|
||||
}.isNotEmpty()
|
||||
if (hasPresence) return true
|
||||
|
||||
return channel.notes
|
||||
.filter { _, value ->
|
||||
acceptableChatEvent(value, filterParams, timeLimit)
|
||||
@@ -284,6 +294,18 @@ class HomeLiveFilter(
|
||||
}
|
||||
}
|
||||
|
||||
// Audio-room presence is indexed separately from `notes`. Add
|
||||
// its author count so audio-room hosts/speakers still factor
|
||||
// into the follow-participation sort even when they haven't
|
||||
// chatted in the room.
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
channel.presenceNotes.forEach { authorHex, _ ->
|
||||
if (followingSet == null || authorHex in followingSet) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -291,9 +291,9 @@ private fun observeRoomLatestPresence(
|
||||
channel
|
||||
.flow()
|
||||
.notes.stateFlow
|
||||
.mapLatest { state ->
|
||||
.mapLatest { _ ->
|
||||
var max: Long? = null
|
||||
state.channel.notes.forEach { _, note ->
|
||||
channel.presenceNotes.forEach { _, note ->
|
||||
val event = note.event
|
||||
if (event is MeetingRoomPresenceEvent) {
|
||||
val createdAt = event.createdAt
|
||||
|
||||
+24
-17
@@ -90,7 +90,7 @@ class NestsFeedFilter(
|
||||
val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false
|
||||
if (!hasMinimumNestFields(noteEvent)) return@filterTo false
|
||||
if (!isWithinPlannedWindow(noteEvent, now)) return@filterTo false
|
||||
if (!isLiveByPresence(noteEvent, presenceCutoff)) return@filterTo false
|
||||
if (!hasFreshSpeakers(noteEvent, presenceCutoff)) return@filterTo false
|
||||
|
||||
if (filterParams.match(noteEvent, it.relays)) return@filterTo true
|
||||
|
||||
@@ -151,19 +151,24 @@ class NestsFeedFilter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop OPEN/PRIVATE rooms that have no kind-10312 presence in the
|
||||
* last [PRESENCE_FRESHNESS_WINDOW_SECONDS] AND were not created in
|
||||
* the same window. The "created recently" grace lets brand-new
|
||||
* rooms surface before any speaker has had time to publish their
|
||||
* first heartbeat. Mirrors the NostrNests lobby gate — needs the
|
||||
* lobby-wide kind-10312 REQ added in `filterNestsGlobal` to be
|
||||
* meaningful.
|
||||
* Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room
|
||||
* with no fresh kind-10312 presence carrying `onstage=1` published
|
||||
* in the last [PRESENCE_FRESHNESS_WINDOW_SECONDS] has no one left
|
||||
* on stage — even if the kind-30312 status still says `live`,
|
||||
* there is nothing to listen to and the room has effectively
|
||||
* ended. Mirrors (and tightens) the NostrNests lobby gate.
|
||||
*
|
||||
* CLOSED and PLANNED rooms bypass this gate: CLOSED rooms may
|
||||
* carry a recording (EGG-11), and PLANNED rooms have not started
|
||||
* yet so no presence is expected.
|
||||
* Brand-new rooms get a created-at grace so they surface before
|
||||
* the first speaker heartbeat arrives. CLOSED rooms bypass this
|
||||
* gate (they may carry a recording — EGG-11), as do PLANNED rooms
|
||||
* (not started yet, no presence expected).
|
||||
*
|
||||
* Reads the room's [LiveActivitiesChannel.presenceNotes] index
|
||||
* (keyed by author, populated by
|
||||
* `LocalCache.consume(MeetingRoomPresenceEvent)`) so the scan is
|
||||
* O(speakers) instead of O(all chat + zaps + presence).
|
||||
*/
|
||||
private fun isLiveByPresence(
|
||||
private fun hasFreshSpeakers(
|
||||
event: MeetingSpaceEvent,
|
||||
presenceCutoff: Long,
|
||||
): Boolean {
|
||||
@@ -172,13 +177,15 @@ class NestsFeedFilter(
|
||||
if (event.createdAt > presenceCutoff) return true
|
||||
|
||||
val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false
|
||||
var fresh = false
|
||||
channel.notes.forEach { _, note ->
|
||||
if (fresh) return@forEach
|
||||
var hasSpeaker = false
|
||||
channel.presenceNotes.forEach { _, note ->
|
||||
if (hasSpeaker) return@forEach
|
||||
val e = note.event
|
||||
if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff) fresh = true
|
||||
if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff && e.onstage() == true) {
|
||||
hasSpeaker = true
|
||||
}
|
||||
}
|
||||
return fresh
|
||||
return hasSpeaker
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
@@ -26,9 +26,11 @@ import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
|
||||
@Stable
|
||||
class LiveActivitiesChannel(
|
||||
@@ -40,6 +42,65 @@ class LiveActivitiesChannel(
|
||||
// Important to keep this long-term reference because LocalCache uses WeakReferences.
|
||||
var infoNote: Note? = null
|
||||
|
||||
/**
|
||||
* Audio-room presence index (NIP-53 kind-10312) keyed by author
|
||||
* pubkey. Presence is replaceable (one per author), so keying on
|
||||
* the author auto-collapses heartbeat versions instead of growing
|
||||
* unbounded the way `notes` does. Empty for streaming channels
|
||||
* (kind-30311) — only kind-30312 rooms publish presence.
|
||||
*
|
||||
* Presence lives ONLY here, not in the base `notes` map: the
|
||||
* mixed-kind `notes` is dominated by chat in active rooms and
|
||||
* iterating it just to find presence is wasteful. Feeds that need
|
||||
* "is anyone live on stage in this room?" iterate this index
|
||||
* directly. See
|
||||
* [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter]
|
||||
* and [com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter].
|
||||
*/
|
||||
val presenceNotes = LargeCache<HexKey, Note>()
|
||||
|
||||
fun addPresenceNote(note: Note) {
|
||||
val author = note.author?.pubkeyHex ?: return
|
||||
val previous = presenceNotes.get(author)
|
||||
if (previous?.idHex == note.idHex) return
|
||||
presenceNotes.put(author, note)
|
||||
flowSet?.notes?.invalidateData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an author's presence entry. Called by `LocalCache` when a
|
||||
* replaceable kind-10312 from this author lands in a *different*
|
||||
* room — without this eviction, the old room would keep surfacing
|
||||
* as "live" via stale presence until it drops out of the
|
||||
* freshness window.
|
||||
*/
|
||||
fun removePresenceNote(author: HexKey) {
|
||||
if (!presenceNotes.containsKey(author)) return
|
||||
presenceNotes.remove(author)
|
||||
flowSet?.notes?.invalidateData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop presence entries older than [cutoff]. Without this, every
|
||||
* author who ever heartbeats in this room would leave an entry
|
||||
* here forever, even though the freshness check in
|
||||
* `NestsFeedFilter` already treats anything past
|
||||
* `PRESENCE_FRESHNESS_WINDOW_SECONDS` (10 min) as dead. Run on the
|
||||
* same schedule as [pruneOldMessages] with a generous cutoff (2×
|
||||
* the freshness window) so a presence still inside any feed's
|
||||
* window can never be pruned.
|
||||
*/
|
||||
fun pruneStalePresence(cutoff: Long): Int {
|
||||
val toRemove = mutableListOf<HexKey>()
|
||||
presenceNotes.forEach { author, note ->
|
||||
val createdAt = note.event?.createdAt
|
||||
if (createdAt == null || createdAt < cutoff) toRemove.add(author)
|
||||
}
|
||||
toRemove.forEach { presenceNotes.remove(it) }
|
||||
if (toRemove.isNotEmpty()) flowSet?.notes?.invalidateData()
|
||||
return toRemove.size
|
||||
}
|
||||
|
||||
fun address() = address
|
||||
|
||||
override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()
|
||||
|
||||
Reference in New Issue
Block a user