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.Note
|
||||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||||
import com.vitorpamplona.amethyst.model.TopFilter
|
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.AllFollowsByOutboxTopNavFilter
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
|
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
|
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.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
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.MeetingSpaceEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
|
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).
|
* 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)
|
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||||
|
|
||||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||||
|
val topFilter = account.liveLiveStreamsFollowLists.value
|
||||||
val filterParams =
|
val filterParams =
|
||||||
FilterByListParams.create(
|
FilterByListParams.create(
|
||||||
followLists = account.liveLiveStreamsFollowLists.value,
|
followLists = topFilter,
|
||||||
hiddenUsers = account.hiddenUsers.flow.value,
|
hiddenUsers = account.hiddenUsers.flow.value,
|
||||||
)
|
)
|
||||||
|
val expandableAuthors = followsAuthorsForExpansion(topFilter)
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
val presenceCutoff = now - PRESENCE_FRESHNESS_WINDOW_SECONDS
|
||||||
|
|
||||||
return collection.filterTo(HashSet()) {
|
return collection.filterTo(HashSet()) {
|
||||||
val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false
|
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
|
* relays sometimes leak) don't render an empty card with a broken
|
||||||
* Join button.
|
* 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
|
* Closed rooms with all four fields ARE rendered — they may carry a
|
||||||
* recording (EGG-11) and the listen-back card is the audience's
|
* recording (EGG-11) and the listen-back card is the audience's
|
||||||
* only path to that audio post-close.
|
* only path to that audio post-close.
|
||||||
*/
|
*/
|
||||||
private fun hasMinimumNestFields(event: MeetingSpaceEvent): Boolean =
|
private fun hasMinimumNestFields(event: MeetingSpaceEvent): Boolean {
|
||||||
!event.room().isNullOrBlank() &&
|
val service = event.service()
|
||||||
|
val endpoint = event.endpoint()
|
||||||
|
return !event.room().isNullOrBlank() &&
|
||||||
event.status() != null &&
|
event.status() != null &&
|
||||||
!event.service().isNullOrBlank() &&
|
!service.isNullOrBlank() &&
|
||||||
!event.endpoint().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> {
|
override fun sort(items: Set<Note>): List<Note> {
|
||||||
val topFilter = account.liveLiveStreamsFollowLists.value
|
val topFilter = account.liveLiveStreamsFollowLists.value
|
||||||
@@ -140,4 +236,27 @@ class NestsFeedFilter(
|
|||||||
StatusTag.STATUS.CLOSED -> 0
|
StatusTag.STATUS.CLOSED -> 0
|
||||||
else -> 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.filterNestsByGeohash
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsByHashtag
|
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.filterNestsGlobal
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.subassemblies.filterNestsPresence
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
|
|
||||||
fun makeNestsFilter(
|
fun makeNestsFilter(
|
||||||
feedSettings: IFeedTopNavPerRelayFilterSet,
|
feedSettings: IFeedTopNavPerRelayFilterSet,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
defaultSince: Long? = null,
|
defaultSince: Long? = null,
|
||||||
): List<RelayBasedFilter> =
|
): List<RelayBasedFilter> {
|
||||||
when (feedSettings) {
|
val rooms =
|
||||||
is AllCommunitiesTopNavPerRelayFilterSet -> filterNestsByAllCommunities(feedSettings, since, defaultSince)
|
when (feedSettings) {
|
||||||
is AllFollowsTopNavPerRelayFilterSet -> filterNestsByFollows(feedSettings, since, defaultSince)
|
is AllCommunitiesTopNavPerRelayFilterSet -> filterNestsByAllCommunities(feedSettings, since, defaultSince)
|
||||||
is AuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince)
|
is AllFollowsTopNavPerRelayFilterSet -> filterNestsByFollows(feedSettings, since, defaultSince)
|
||||||
is GlobalTopNavPerRelayFilterSet -> filterNestsGlobal(feedSettings, since, defaultSince)
|
is AuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince)
|
||||||
is HashtagTopNavPerRelayFilterSet -> filterNestsByHashtag(feedSettings, since, defaultSince)
|
is GlobalTopNavPerRelayFilterSet -> filterNestsGlobal(feedSettings, since, defaultSince)
|
||||||
is LocationTopNavPerRelayFilterSet -> filterNestsByGeohash(feedSettings, since, defaultSince)
|
is HashtagTopNavPerRelayFilterSet -> filterNestsByHashtag(feedSettings, since, defaultSince)
|
||||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince)
|
is LocationTopNavPerRelayFilterSet -> filterNestsByGeohash(feedSettings, since, defaultSince)
|
||||||
is SingleCommunityTopNavPerRelayFilterSet -> filterNestsByCommunity(feedSettings, since, defaultSince)
|
is MutedAuthorsTopNavPerRelayFilterSet -> filterNestsByAuthors(feedSettings, since, defaultSince)
|
||||||
else -> emptyList()
|
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.amethyst.service.relays.SincePerRelayMap
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
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.MeetingRoomEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
|
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
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(
|
fun filterNestsGlobal(
|
||||||
relays: GlobalTopNavPerRelayFilterSet,
|
relays: GlobalTopNavPerRelayFilterSet,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
@@ -37,7 +70,7 @@ fun filterNestsGlobal(
|
|||||||
|
|
||||||
return relays.set
|
return relays.set
|
||||||
.map {
|
.map {
|
||||||
val since = since?.get(it.key)?.time ?: defaultSince
|
val roomsSince = since?.get(it.key)?.time ?: defaultSince
|
||||||
listOf(
|
listOf(
|
||||||
RelayBasedFilter(
|
RelayBasedFilter(
|
||||||
relay = it.key,
|
relay = it.key,
|
||||||
@@ -45,9 +78,10 @@ fun filterNestsGlobal(
|
|||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND),
|
kinds = listOf(MeetingSpaceEvent.KIND, MeetingRoomEvent.KIND),
|
||||||
limit = 300,
|
limit = 300,
|
||||||
since = since ?: TimeUtils.oneWeekAgo(),
|
since = roomsSince ?: TimeUtils.oneWeekAgo(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
filterNestsPresence(it.key),
|
||||||
)
|
)
|
||||||
}.flatten()
|
}.flatten()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user