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 24e660ef4..9dee631e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 485b97765..fea85e90b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -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 = sort(channel.notes.filterIntoSet { _, it -> isChatEvent(it) && account.isAcceptable(it) }) + override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) }) override fun applyFilter(newItems: Set): Set = 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): List = 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 491189e5a..29d067e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 72b40ccc5..0bf6c241d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index b9f071612..2564a2311 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -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 } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index 6a49058da..1638b54d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -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() + + 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() + 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()