feat(nests): bucket feed into Live / Scheduled / Recently ended sections
Replace the flat NestsScreen list with three explicit sections so the audio-room surface separates actionable rooms from historic ones: - LIVE: ordered by follows-participating DESC, total-participants DESC, createdAt DESC. PRIVATE rooms continue to live in the LIVE bucket since they're audible once a join token is granted. - SCHEDULED: ordered by `starts` ASC (soonest first). PLANNED rooms now have their own bucket instead of collapsing into ENDED. - ENDED: ordered by createdAt DESC (proxy for "ended at"), capped to the last 7 d, and rendered with a compact one-line card variant (square thumb + name + host + "Ended Xh ago"). Tapping still routes through the read-only lobby so recordings remain reachable. The DAL exposes `NestsFeedFilter.bucketOf()` so the screen can walk the pre-sorted feed once and inject sticky-header sections without re-sorting on each recomposition. Cards now wrap in `LongPressToQuickAction`, giving share / copy / delete / report / block / broadcast on long press — matching the NoteCompose interaction model and giving hosts a path to delete (kind:5) ended rooms without entering the room first.
This commit is contained in:
+275
-37
@@ -21,7 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,9 +33,11 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -50,6 +53,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -69,15 +73,19 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
|
||||
import com.vitorpamplona.amethyst.ui.note.Gallery
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.PrivateFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter.NestBucket
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessProbeSubscription
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
||||
@@ -89,8 +97,10 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
@@ -116,27 +126,125 @@ fun NestsFeedLoaded(
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
// The DAL emits items pre-sorted in bucket order (LIVE → SCHEDULED
|
||||
// → ENDED). Walk the list once to find bucket boundaries so we
|
||||
// can inject sticky section headers without re-sorting on every
|
||||
// recomposition.
|
||||
val sections =
|
||||
remember(items.list) {
|
||||
val live = ArrayList<Note>()
|
||||
val scheduled = ArrayList<Note>()
|
||||
val ended = ArrayList<Note>()
|
||||
items.list.forEach {
|
||||
when (NestsFeedFilter.bucketOf(it.event as? MeetingSpaceEvent)) {
|
||||
NestBucket.LIVE -> live.add(it)
|
||||
NestBucket.SCHEDULED -> scheduled.add(it)
|
||||
NestBucket.ENDED -> ended.add(it)
|
||||
}
|
||||
}
|
||||
Sections(
|
||||
live = live.toImmutableList(),
|
||||
scheduled = scheduled.toImmutableList(),
|
||||
ended = ended.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
// Hoist string lookups out of the LazyListScope builder lambda
|
||||
// (which is not @Composable) so they can be passed in as plain
|
||||
// strings.
|
||||
val liveLabel = stringRes(R.string.nests_section_live_now)
|
||||
val scheduledLabel = stringRes(R.string.nests_section_scheduled)
|
||||
val endedLabel = stringRes(R.string.nests_section_recently_ended)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NestFeedCard(
|
||||
baseNote = item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
if (sections.live.isNotEmpty()) {
|
||||
sectionHeader("live", liveLabel)
|
||||
fullSection(sections.live, accountViewModel, nav)
|
||||
}
|
||||
if (sections.scheduled.isNotEmpty()) {
|
||||
sectionHeader("scheduled", scheduledLabel)
|
||||
fullSection(sections.scheduled, accountViewModel, nav)
|
||||
}
|
||||
if (sections.ended.isNotEmpty()) {
|
||||
sectionHeader("ended", endedLabel)
|
||||
compactSection(sections.ended, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
@Immutable
|
||||
private data class Sections(
|
||||
val live: ImmutableList<Note>,
|
||||
val scheduled: ImmutableList<Note>,
|
||||
val ended: ImmutableList<Note>,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun LazyListScope.sectionHeader(
|
||||
key: String,
|
||||
title: String,
|
||||
) {
|
||||
stickyHeader(key = "header-$key") {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun LazyListScope.fullSection(
|
||||
rooms: ImmutableList<Note>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
items(rooms.size, key = { rooms[it].idHex }) { index ->
|
||||
val item = rooms[index]
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NestFeedCard(
|
||||
baseNote = item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun LazyListScope.compactSection(
|
||||
rooms: ImmutableList<Note>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
items(rooms.size, key = { rooms[it].idHex }) { index ->
|
||||
val item = rooms[index]
|
||||
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||
NestEndedCompactCard(
|
||||
baseNote = item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio-rooms list card. Mirrors [ObserveAndRenderSpace] visually and
|
||||
* gates a tap on the underlying [MeetingSpaceEvent] by the same liveness
|
||||
@@ -147,7 +255,13 @@ fun NestsFeedLoaded(
|
||||
* a dead room. Cards that the UI still shows as live, scheduled, or
|
||||
* private launch [NestActivity] directly — the lobby's purpose is only
|
||||
* to gate stale rooms.
|
||||
*
|
||||
* Long-pressing the card opens the standard [NoteQuickActionMenu] from
|
||||
* [LongPressToQuickAction], so authors can delete (kind:5 against the
|
||||
* room's `a`-tag) and listeners can share/copy the room link without
|
||||
* needing to enter the room first.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun NestFeedCard(
|
||||
baseNote: Note,
|
||||
@@ -182,37 +296,161 @@ private fun NestFeedCard(
|
||||
val onClick =
|
||||
remember(meetingEvent, isUiClosed) {
|
||||
{
|
||||
val service = meetingEvent.service()
|
||||
val endpoint = meetingEvent.endpoint()
|
||||
val dTag = meetingEvent.address().dTag
|
||||
if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) {
|
||||
if (isUiClosed) {
|
||||
nav.nav(Route.NestLobby(meetingEvent.address().toValue()))
|
||||
} else {
|
||||
NestBridge.set(accountViewModel)
|
||||
NestActivity.launch(
|
||||
context = context,
|
||||
addressValue = meetingEvent.address().toValue(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
||||
}
|
||||
openRoom(meetingEvent, baseNote, isUiClosed, accountViewModel, nav, context)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier.clickable(onClick = onClick)) {
|
||||
Column(StdPadding) {
|
||||
SensitivityWarning(
|
||||
note = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
ObserveAndRenderSpace(addressableNote, accountViewModel, nav)
|
||||
LongPressToQuickAction(baseNote, accountViewModel, nav) { showQuickAction ->
|
||||
Column(
|
||||
modifier
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = showQuickAction,
|
||||
),
|
||||
) {
|
||||
Column(StdPadding) {
|
||||
SensitivityWarning(
|
||||
note = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
ObserveAndRenderSpace(addressableNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact one-line variant for the "Recently ended" bucket. Skips the
|
||||
* 16:9 hero, participants gallery, and reaction row to keep the
|
||||
* historic list scannable while preserving the entry point — tapping
|
||||
* still routes through [Route.NestLobby] so users can open the
|
||||
* recording (NIP-53 EGG-11) if one is attached.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun NestEndedCompactCard(
|
||||
baseNote: Note,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val meetingEvent = baseNote.event as? MeetingSpaceEvent ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val onClick =
|
||||
remember(meetingEvent) {
|
||||
{
|
||||
openRoom(meetingEvent, baseNote, isUiClosed = true, accountViewModel, nav, context)
|
||||
}
|
||||
}
|
||||
|
||||
val name = meetingEvent.room()?.ifBlank { null } ?: meetingEvent.dTag()
|
||||
val cover = meetingEvent.image()?.ifBlank { null }
|
||||
val endedAt = baseNote.createdAt()
|
||||
val endedAgo = endedAt?.let { timeAgoNoDot(it, context) }.orEmpty()
|
||||
|
||||
LongPressToQuickAction(baseNote, accountViewModel, nav) { showQuickAction ->
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = showQuickAction,
|
||||
).padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size55dp)
|
||||
.clip(QuoteBorder),
|
||||
) {
|
||||
if (cover != null) {
|
||||
AsyncImage(
|
||||
model = cover,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
val addressable = baseNote as? AddressableNote
|
||||
if (addressable != null) {
|
||||
DisplayAuthorBanner(addressable, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val creator = baseNote.author
|
||||
if (creator != null) {
|
||||
UsernameDisplay(
|
||||
baseUser = creator,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textColor = MaterialTheme.colorScheme.grayText,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
if (endedAgo.isNotEmpty()) {
|
||||
stringRes(R.string.nests_ended_ago, endedAgo)
|
||||
} else {
|
||||
stringRes(R.string.live_stream_ended_tag)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the destination for tapping a room card and navigate.
|
||||
* Stale-promoted LIVE rooms and ENDED rooms route to the lobby
|
||||
* (read-only) so re-entry doesn't kick off the audio pipeline or
|
||||
* trigger host-side kind-30312 refreshes on a dead room. Live and
|
||||
* private rooms launch [NestActivity] directly. Rooms missing
|
||||
* service/endpoint tags fall back to the standard note route so
|
||||
* the user at least sees what we know about the event.
|
||||
*/
|
||||
private fun openRoom(
|
||||
meetingEvent: MeetingSpaceEvent,
|
||||
baseNote: Note,
|
||||
isUiClosed: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
val service = meetingEvent.service()
|
||||
val endpoint = meetingEvent.endpoint()
|
||||
val dTag = meetingEvent.address().dTag
|
||||
if (!service.isNullOrBlank() && !endpoint.isNullOrBlank() && dTag.isNotBlank()) {
|
||||
if (isUiClosed) {
|
||||
nav.nav(Route.NestLobby(meetingEvent.address().toValue()))
|
||||
} else {
|
||||
NestBridge.set(accountViewModel)
|
||||
NestActivity.launch(
|
||||
context = context,
|
||||
addressValue = meetingEvent.address().toValue(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveAndRenderSpace(
|
||||
baseNote: AddressableNote,
|
||||
@@ -231,7 +469,7 @@ fun ObserveAndRenderSpace(
|
||||
content = noteEvent.summary(),
|
||||
participants = noteEvent.participants().toImmutableList(),
|
||||
status = noteEvent.checkStatus(noteEvent.status()),
|
||||
starts = null,
|
||||
starts = noteEvent.starts(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+96
-21
@@ -85,11 +85,13 @@ class NestsFeedFilter(
|
||||
val expandableAuthors = followsAuthorsForExpansion(topFilter)
|
||||
val now = TimeUtils.now()
|
||||
val presenceCutoff = now - PRESENCE_FRESHNESS_WINDOW_SECONDS
|
||||
val endedHistoryCutoff = now - ENDED_HISTORY_WINDOW_SECONDS
|
||||
|
||||
return collection.filterTo(HashSet()) {
|
||||
val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false
|
||||
if (!hasMinimumNestFields(noteEvent)) return@filterTo false
|
||||
if (!isWithinPlannedWindow(noteEvent, now)) return@filterTo false
|
||||
if (!isWithinEndedHistoryWindow(noteEvent, endedHistoryCutoff)) return@filterTo false
|
||||
if (!hasFreshSpeakers(noteEvent, presenceCutoff)) return@filterTo false
|
||||
|
||||
if (filterParams.match(noteEvent, it.relays)) return@filterTo true
|
||||
@@ -150,6 +152,23 @@ class NestsFeedFilter(
|
||||
starts < now + PLANNED_MAX_FUTURE_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* For ENDED rooms only: cap the historical bucket so the feed
|
||||
* doesn't drown in months-old recordings. Anything within
|
||||
* [ENDED_HISTORY_WINDOW_SECONDS] of `now` (compared against the
|
||||
* room's most recent kind-30312 createdAt — the closest signal
|
||||
* we have to "ended at") passes; older entries drop. Other
|
||||
* statuses pass through.
|
||||
*/
|
||||
private fun isWithinEndedHistoryWindow(
|
||||
event: MeetingSpaceEvent,
|
||||
endedHistoryCutoff: Long,
|
||||
): Boolean {
|
||||
val status = event.checkStatus(event.status())
|
||||
if (status != StatusTag.STATUS.ENDED) return true
|
||||
return event.createdAt >= endedHistoryCutoff
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room
|
||||
* with no fresh kind-10312 presence carrying `onstage=1` published
|
||||
@@ -204,6 +223,14 @@ class NestsFeedFilter(
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-bucket sort: LIVE rooms first (ordered by how many of the
|
||||
* user's follows are participating, then by total participants),
|
||||
* SCHEDULED rooms next (soonest start first), ENDED rooms last
|
||||
* (most-recently-ended first). The screen relies on this strict
|
||||
* ordering to walk the list and inject sticky section headers
|
||||
* without re-sorting.
|
||||
*/
|
||||
override fun sort(items: Set<Note>): List<Note> {
|
||||
val topFilter = account.liveNestsFollowLists.value
|
||||
val topFilterAuthors =
|
||||
@@ -221,30 +248,54 @@ class NestsFeedFilter(
|
||||
val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts = items.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
||||
val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) }
|
||||
val live = ArrayList<Note>()
|
||||
val scheduled = ArrayList<Note>()
|
||||
val ended = ArrayList<Note>()
|
||||
|
||||
return items
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ convertStatusToOrder(it.event as? MeetingSpaceEvent) },
|
||||
{ participantCounts[it] },
|
||||
{ allParticipants[it] },
|
||||
{ it.createdAt() },
|
||||
{ it.idHex },
|
||||
),
|
||||
).reversed()
|
||||
}
|
||||
|
||||
private fun convertStatusToOrder(event: MeetingSpaceEvent?): Int =
|
||||
when (event?.status()) {
|
||||
StatusTag.STATUS.LIVE -> 2
|
||||
StatusTag.STATUS.PRIVATE -> 1
|
||||
StatusTag.STATUS.ENDED -> 0
|
||||
else -> 0
|
||||
items.forEach {
|
||||
when (bucketOf(it.event as? MeetingSpaceEvent)) {
|
||||
NestBucket.LIVE -> live.add(it)
|
||||
NestBucket.SCHEDULED -> scheduled.add(it)
|
||||
NestBucket.ENDED -> ended.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// LIVE: follows-participating DESC, total participants DESC,
|
||||
// createdAt DESC, idHex (stable tiebreak).
|
||||
val followsParticipating = live.associateWith { counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
||||
val totalParticipating = live.associateWith { counter.countFollowsThatParticipateOn(it, null) }
|
||||
live.sortWith(
|
||||
compareByDescending<Note> { followsParticipating[it] ?: 0 }
|
||||
.thenByDescending { totalParticipating[it] ?: 0 }
|
||||
.thenByDescending { it.createdAt() ?: 0 }
|
||||
.thenBy { it.idHex },
|
||||
)
|
||||
|
||||
// SCHEDULED: starts ASC (soonest first); rooms missing a starts
|
||||
// tag fall to the end.
|
||||
scheduled.sortWith(
|
||||
compareBy<Note> { (it.event as? MeetingSpaceEvent)?.starts() ?: Long.MAX_VALUE }
|
||||
.thenBy { it.idHex },
|
||||
)
|
||||
|
||||
// ENDED: createdAt DESC (most-recently-ended first; the kind
|
||||
// 30312 createdAt advances when the host republishes with
|
||||
// status=ended, so it's our closest "ended at" signal).
|
||||
ended.sortWith(
|
||||
compareByDescending<Note> { it.createdAt() ?: 0 }
|
||||
.thenBy { it.idHex },
|
||||
)
|
||||
|
||||
return live + scheduled + ended
|
||||
}
|
||||
|
||||
enum class NestBucket {
|
||||
LIVE,
|
||||
SCHEDULED,
|
||||
ENDED,
|
||||
}
|
||||
|
||||
companion object Companion {
|
||||
/**
|
||||
* Window inside which a kind-10312 presence event still counts
|
||||
* an OPEN room as live. Same 10-minute cutoff NostrNests uses
|
||||
@@ -265,5 +316,29 @@ class NestsFeedFilter(
|
||||
* future are likely spam or mis-set timestamps.
|
||||
*/
|
||||
private const val PLANNED_MAX_FUTURE_SECONDS = 30L * 24L * 60L * 60L
|
||||
|
||||
/**
|
||||
* ENDED rooms older than 7 d drop out of the historic bucket.
|
||||
* The audience can still reach a recording via direct link;
|
||||
* the feed just stops surfacing it.
|
||||
*/
|
||||
private const val ENDED_HISTORY_WINDOW_SECONDS = 7L * 24L * 60L * 60L
|
||||
|
||||
/**
|
||||
* Maps a meeting-space event to its display bucket. PRIVATE
|
||||
* rooms are live (the audience can hear them once they get
|
||||
* a join token); only PLANNED rooms go to SCHEDULED. Any
|
||||
* unknown / stale-promoted status falls through to ENDED.
|
||||
*/
|
||||
fun bucketOf(event: MeetingSpaceEvent?): NestBucket =
|
||||
when (event?.checkStatus(event.status())) {
|
||||
StatusTag.STATUS.LIVE,
|
||||
StatusTag.STATUS.PRIVATE,
|
||||
-> NestBucket.LIVE
|
||||
|
||||
StatusTag.STATUS.PLANNED -> NestBucket.SCHEDULED
|
||||
|
||||
else -> NestBucket.ENDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +596,10 @@
|
||||
<string name="nest_unjoinable_body">The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect.</string>
|
||||
<string name="nest_unjoinable_back">Back</string>
|
||||
<string name="nest_create_fab">Start space</string>
|
||||
<string name="nests_section_live_now">Live now</string>
|
||||
<string name="nests_section_scheduled">Scheduled</string>
|
||||
<string name="nests_section_recently_ended">Recently ended</string>
|
||||
<string name="nests_ended_ago">Ended %1$s ago</string>
|
||||
<string name="nest_no_server_title">Set up a nest server</string>
|
||||
<string name="nest_no_server_body">You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings.</string>
|
||||
<string name="nest_no_server_use_default">Use default</string>
|
||||
|
||||
Reference in New Issue
Block a user