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..49bf611fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1556,6 +1556,10 @@ object LocalCache : ILocalCache, ICacheProvider { * 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). + * + * Also indexed under [LiveActivitiesChannel.presenceNotes] keyed + * by author so the Nests feed can answer "are there speakers on + * stage?" without scanning the chat-dominated `notes` map. */ fun consume( event: MeetingRoomPresenceEvent, @@ -1573,6 +1577,7 @@ object LocalCache : ILocalCache, ICacheProvider { val channel = getOrCreateLiveChannel(roomAddress) val versionNote = getOrCreateNote(event.id) channel.addNote(versionNote, relay) + channel.addPresenceNote(versionNote) return new } 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 9a3697edc..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,6 @@ 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,47 +150,23 @@ class NestsFeedFilter( starts < now + PLANNED_MAX_FUTURE_SECONDS } - /** - * 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. - * - * 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. - */ - private fun isLiveByPresence( - event: MeetingSpaceEvent, - presenceCutoff: Long, - ): Boolean { - val status = event.status() - if (status != StatusTag.STATUS.LIVE && status != StatusTag.STATUS.PRIVATE) return true - 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 - val e = note.event - if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff) fresh = true - } - return fresh - } - /** * Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room - * with no fresh kind-10312 presence carrying `onstage=1` 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. + * 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. * - * Same created-at grace as [isLiveByPresence] so brand-new rooms - * surface before the first speaker heartbeat arrives. CLOSED and - * PLANNED rooms bypass this gate for the same reasons listed on - * [isLiveByPresence]. + * 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 hasFreshSpeakers( event: MeetingSpaceEvent, @@ -203,7 +178,7 @@ class NestsFeedFilter( val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false var hasSpeaker = false - channel.notes.forEach { _, note -> + channel.presenceNotes.forEach { _, note -> if (hasSpeaker) return@forEach val e = note.event if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff && e.onstage() == true) { 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..fc982ddf9 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,24 @@ 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. + * + * Lets feeds answer "is anyone live on stage in this room?" + * without scanning the chat-dominated `notes` map. See + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter]. + */ + val presenceNotes = LargeCache() + + fun addPresenceNote(note: Note) { + val author = note.author?.pubkeyHex ?: return + presenceNotes.put(author, note) + } + fun address() = address override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()