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.
This commit is contained in:
+125
-6
@@ -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<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
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<HexKey>? =
|
||||
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<Note>): List<Note> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+29
-12
@@ -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<RelayBasedFilter> =
|
||||
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<RelayBasedFilter> {
|
||||
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
|
||||
}
|
||||
|
||||
+36
-2
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user