From 0c875b150be48362e9a787fba63c72e549f3434a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 22:45:18 +0000 Subject: [PATCH] feat(nests): tighten lobby filter to mirror NostrNests Bring the audio-room lobby filter closer to the NostrNests reference: - Reject service/endpoint URLs that aren't HTTPS, dropping legacy `wss+livekit://` rooms a moq-lite client can't reach. - Drop PLANNED rooms whose `starts` is more than 1 h in the past or more than 30 d in the future. - Add a single lobby-wide kind-10312 REQ (10 min, limit 500) per active relay and use it to hide OPEN/PRIVATE rooms whose host crashed without flipping status to CLOSED. Brand-new rooms keep showing for the freshness window so they don't pop in late. - Expand follow-style top filters to include rooms whose p-tagged speakers are followed, not just the host. --- .../loggedIn/nests/dal/NestsFeedFilter.kt | 131 +++++++++++++++++- .../nests/datasource/SubAssemblyHelper.kt | 41 ++++-- .../subassemblies/FilterNestsGlobal.kt | 38 ++++- 3 files changed, 190 insertions(+), 20 deletions(-) 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 90c06ca77..f49fdd98f 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter @@ -34,8 +35,11 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.utils.TimeUtils /** * Drawer feed for NIP-53 kind 30312 (Interactive Rooms / audio spaces). @@ -72,15 +76,34 @@ class NestsFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) private fun innerApplyFilter(collection: Collection): Set { + val topFilter = account.liveLiveStreamsFollowLists.value val filterParams = FilterByListParams.create( - followLists = account.liveLiveStreamsFollowLists.value, + followLists = topFilter, hiddenUsers = account.hiddenUsers.flow.value, ) + val expandableAuthors = followsAuthorsForExpansion(topFilter) + val now = TimeUtils.now() + val presenceCutoff = now - PRESENCE_FRESHNESS_WINDOW_SECONDS return collection.filterTo(HashSet()) { val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false - hasMinimumNestFields(noteEvent) && filterParams.match(noteEvent, it.relays) + if (!hasMinimumNestFields(noteEvent)) return@filterTo false + if (!isWithinPlannedWindow(noteEvent, now)) return@filterTo false + if (!isLiveByPresence(noteEvent, presenceCutoff)) return@filterTo false + + if (filterParams.match(noteEvent, it.relays)) return@filterTo true + + // p-tag follow expansion: surface rooms whose host fails the + // top filter but where any p-tagged speaker is in the user's + // follows. Mirrors NostrNests' "Following" tab. Limited to + // follow-style top filters via [followsAuthorsForExpansion] + // so mute/relay/global filters keep their existing behavior. + val authors = expandableAuthors ?: return@filterTo false + if (!filterParams.isNotInTheFuture(noteEvent)) return@filterTo false + if (!filterParams.isHiddenList && !filterParams.isNotHidden(noteEvent.pubKey)) return@filterTo false + if (filterParams.hasExcessiveHashtags(noteEvent)) return@filterTo false + noteEvent.participantsIntersect(authors) } } @@ -91,15 +114,88 @@ class NestsFeedFilter( * relays sometimes leak) don't render an empty card with a broken * Join button. * + * Also rejects non-HTTPS service/endpoint URLs — mirrors the + * NostrNests guard that drops legacy `wss+livekit://` streaming URLs + * from first-generation servers a moq-lite client cannot reach. + * * Closed rooms with all four fields ARE rendered — they may carry a * recording (EGG-11) and the listen-back card is the audience's * only path to that audio post-close. */ - private fun hasMinimumNestFields(event: MeetingSpaceEvent): Boolean = - !event.room().isNullOrBlank() && + private fun hasMinimumNestFields(event: MeetingSpaceEvent): Boolean { + val service = event.service() + val endpoint = event.endpoint() + return !event.room().isNullOrBlank() && event.status() != null && - !event.service().isNullOrBlank() && - !event.endpoint().isNullOrBlank() + !service.isNullOrBlank() && + !endpoint.isNullOrBlank() && + service.startsWith("https://") && + endpoint.startsWith("https://") + } + + /** + * For PLANNED rooms only: drop entries whose `starts` time is more + * than [PLANNED_STALE_SECONDS] in the past (host never went live) + * or more than [PLANNED_MAX_FUTURE_SECONDS] in the future (likely + * spam or a mis-set timestamp). Mirrors the NostrNests planned + * bucket window. Other statuses pass through. + */ + private fun isWithinPlannedWindow( + event: MeetingSpaceEvent, + now: Long, + ): Boolean { + if (event.status() != StatusTag.STATUS.PLANNED) return true + val starts = event.starts() ?: return true + return starts > now - PLANNED_STALE_SECONDS && + 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.OPEN && 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 + } + + /** + * Author set used for p-tag follow expansion. Returns null for + * top filters where expansion is not meaningful (mute/block, the + * relay filter, global) so we don't accidentally widen feeds the + * user explicitly narrowed to a specific axis. + */ + private fun followsAuthorsForExpansion(topFilter: IFeedTopNavFilter?): Set? = + when (topFilter) { + is AuthorsByOutboxTopNavFilter -> topFilter.authors + is AuthorsByProxyTopNavFilter -> topFilter.authors + is AllFollowsByOutboxTopNavFilter -> topFilter.authors + is AllFollowsByProxyTopNavFilter -> topFilter.authors + is SingleCommunityTopNavFilter -> topFilter.authors + else -> null + } override fun sort(items: Set): List { val topFilter = account.liveLiveStreamsFollowLists.value @@ -140,4 +236,27 @@ class NestsFeedFilter( StatusTag.STATUS.CLOSED -> 0 else -> 0 } + + companion object { + /** + * Window inside which a kind-10312 presence event still counts + * an OPEN room as live. Same 10-minute cutoff NostrNests uses + * in its lobby. Speakers heartbeat every ~60 s, so a 10-minute + * window tolerates up to ~9 missed heartbeats before the room + * is hidden as crashed. + */ + private const val PRESENCE_FRESHNESS_WINDOW_SECONDS = 10L * 60L + + /** + * PLANNED rooms whose `starts` is more than 1 h in the past + * are considered stale: the host never opened the room. + */ + private const val PLANNED_STALE_SECONDS = 60L * 60L + + /** + * PLANNED rooms whose `starts` is more than 30 d in the + * future are likely spam or mis-set timestamps. + */ + private const val PLANNED_MAX_FUTURE_SECONDS = 30L * 24L * 60L * 60L + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt index 63f6f1bbe..995943746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/SubAssemblyHelper.kt @@ -37,21 +37,38 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassembl import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByGeohash import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByHashtag import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsGlobal +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsPresence import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter fun makeNestsFilter( feedSettings: IFeedTopNavPerRelayFilterSet, since: SincePerRelayMap?, defaultSince: Long? = null, -): List = - when (feedSettings) { - is AllCommunitiesTopNavPerRelayFilterSet -> filterNestsByAllCommunities(feedSettings, since, defaultSince) - is AllFollowsTopNavPerRelayFilterSet -> filterNestsByFollows(feedSettings, since, defaultSince) - is AuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterNestsGlobal(feedSettings, since, defaultSince) - is HashtagTopNavPerRelayFilterSet -> filterNestsByHashtag(feedSettings, since, defaultSince) - is LocationTopNavPerRelayFilterSet -> filterNestsByGeohash(feedSettings, since, defaultSince) - is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince) - is SingleCommunityTopNavPerRelayFilterSet -> filterNestsByCommunity(feedSettings, since, defaultSince) - else -> emptyList() - } +): List { + val rooms = + when (feedSettings) { + is AllCommunitiesTopNavPerRelayFilterSet -> filterNestsByAllCommunities(feedSettings, since, defaultSince) + is AllFollowsTopNavPerRelayFilterSet -> filterNestsByFollows(feedSettings, since, defaultSince) + is AuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterNestsGlobal(feedSettings, since, defaultSince) + is HashtagTopNavPerRelayFilterSet -> filterNestsByHashtag(feedSettings, since, defaultSince) + is LocationTopNavPerRelayFilterSet -> filterNestsByGeohash(feedSettings, since, defaultSince) + is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince) + is SingleCommunityTopNavPerRelayFilterSet -> filterNestsByCommunity(feedSettings, since, defaultSince) + else -> return emptyList() + } + if (rooms.isEmpty()) return rooms + + // Add a single presence probe per relay we're already querying for + // rooms — feeds NestsFeedFilter's freshness gate without forcing + // the per-card subscription to color the live badge. Already + // included by [filterNestsGlobal]; for other top-filters we splice + // it in here so every selection benefits. + if (feedSettings is GlobalTopNavPerRelayFilterSet) return rooms + val presenceByRelay = + rooms + .map { it.relay } + .toSet() + .map { filterNestsPresence(it) } + return rooms + presenceByRelay +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt index 6bb5a8dfe..d82ea93c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/subassemblies/FilterNestsGlobal.kt @@ -24,10 +24,43 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayF import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent import com.vitorpamplona.quartz.utils.TimeUtils +/** + * Window the lobby looks back for kind-10312 presence events. Matches + * the [NestsFeedFilter] freshness gate so the wire fetch and the local + * filter agree on what "live" means. Tighter than the room-event REQ + * (1 week) since presence is a high-volume heartbeat stream we only + * need recent samples of. + */ +private const val PRESENCE_LOOKBACK_SECONDS = 10L * 60L + +/** + * Lobby-wide presence probe so [NestsFeedFilter] can hide OPEN rooms + * whose host crashed without flipping status to CLOSED. Without this + * the only path that fetched kind-10312 was per-room when a card + * mounted, which forced one full assembler subscription per visible + * card just to color the badge. + * + * Returned as a single REQ per relay so it composes with whatever + * room-event filter the active TopFilter built (global, follows, + * authors, hashtag, geohash, community). + */ +fun filterNestsPresence(relay: NormalizedRelayUrl): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(MeetingRoomPresenceEvent.KIND), + limit = 500, + since = TimeUtils.now() - PRESENCE_LOOKBACK_SECONDS, + ), + ) + fun filterNestsGlobal( relays: GlobalTopNavPerRelayFilterSet, since: SincePerRelayMap?, @@ -37,7 +70,7 @@ fun filterNestsGlobal( return relays.set .map { - val since = since?.get(it.key)?.time ?: defaultSince + val roomsSince = since?.get(it.key)?.time ?: defaultSince listOf( RelayBasedFilter( relay = it.key, @@ -45,9 +78,10 @@ fun filterNestsGlobal( Filter( kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND), limit = 300, - since = since ?: TimeUtils.oneWeekAgo(), + since = roomsSince ?: TimeUtils.oneWeekAgo(), ), ), + filterNestsPresence(it.key), ) }.flatten() }