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:
+257
-19
@@ -21,7 +21,8 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -50,6 +53,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
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.DisplayAuthorBanner
|
||||||
import com.vitorpamplona.amethyst.ui.note.Gallery
|
import com.vitorpamplona.amethyst.ui.note.Gallery
|
||||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
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.UserPicture
|
||||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
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.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag
|
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.LiveFlag
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.PrivateFlag
|
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.chats.publicChannels.nip53LiveActivities.ScheduledFlag
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
|
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.datasource.NestRoomLivenessProbeSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
|
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.RowColSpacing
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
import com.vitorpamplona.amethyst.ui.theme.Size34dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
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.StdHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
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.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||||
@@ -116,11 +126,93 @@ fun NestsFeedLoaded(
|
|||||||
) {
|
) {
|
||||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
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(
|
LazyColumn(
|
||||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||||
state = listState,
|
state = listState,
|
||||||
) {
|
) {
|
||||||
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()) {
|
Row(Modifier.fillMaxWidth().animateItem()) {
|
||||||
NestFeedCard(
|
NestFeedCard(
|
||||||
baseNote = item,
|
baseNote = item,
|
||||||
@@ -129,11 +221,27 @@ fun NestsFeedLoaded(
|
|||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(thickness = DividerThickness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalDivider(
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
thickness = DividerThickness,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +255,13 @@ fun NestsFeedLoaded(
|
|||||||
* a dead room. Cards that the UI still shows as live, scheduled, or
|
* a dead room. Cards that the UI still shows as live, scheduled, or
|
||||||
* private launch [NestActivity] directly — the lobby's purpose is only
|
* private launch [NestActivity] directly — the lobby's purpose is only
|
||||||
* to gate stale rooms.
|
* 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
|
@Composable
|
||||||
private fun NestFeedCard(
|
private fun NestFeedCard(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
@@ -182,6 +296,143 @@ private fun NestFeedCard(
|
|||||||
val onClick =
|
val onClick =
|
||||||
remember(meetingEvent, isUiClosed) {
|
remember(meetingEvent, isUiClosed) {
|
||||||
{
|
{
|
||||||
|
openRoom(meetingEvent, baseNote, isUiClosed, accountViewModel, nav, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 service = meetingEvent.service()
|
||||||
val endpoint = meetingEvent.endpoint()
|
val endpoint = meetingEvent.endpoint()
|
||||||
val dTag = meetingEvent.address().dTag
|
val dTag = meetingEvent.address().dTag
|
||||||
@@ -198,19 +449,6 @@ private fun NestFeedCard(
|
|||||||
} else {
|
} else {
|
||||||
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
nav.nav { routeFor(baseNote, accountViewModel.account) }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier.clickable(onClick = onClick)) {
|
|
||||||
Column(StdPadding) {
|
|
||||||
SensitivityWarning(
|
|
||||||
note = baseNote,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
) {
|
|
||||||
ObserveAndRenderSpace(addressableNote, accountViewModel, nav)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -231,7 +469,7 @@ fun ObserveAndRenderSpace(
|
|||||||
content = noteEvent.summary(),
|
content = noteEvent.summary(),
|
||||||
participants = noteEvent.participants().toImmutableList(),
|
participants = noteEvent.participants().toImmutableList(),
|
||||||
status = noteEvent.checkStatus(noteEvent.status()),
|
status = noteEvent.checkStatus(noteEvent.status()),
|
||||||
starts = null,
|
starts = noteEvent.starts(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+94
-19
@@ -85,11 +85,13 @@ class NestsFeedFilter(
|
|||||||
val expandableAuthors = followsAuthorsForExpansion(topFilter)
|
val expandableAuthors = followsAuthorsForExpansion(topFilter)
|
||||||
val now = TimeUtils.now()
|
val now = TimeUtils.now()
|
||||||
val presenceCutoff = now - PRESENCE_FRESHNESS_WINDOW_SECONDS
|
val presenceCutoff = now - PRESENCE_FRESHNESS_WINDOW_SECONDS
|
||||||
|
val endedHistoryCutoff = now - ENDED_HISTORY_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
|
||||||
if (!hasMinimumNestFields(noteEvent)) return@filterTo false
|
if (!hasMinimumNestFields(noteEvent)) return@filterTo false
|
||||||
if (!isWithinPlannedWindow(noteEvent, now)) 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 (!hasFreshSpeakers(noteEvent, presenceCutoff)) return@filterTo false
|
||||||
|
|
||||||
if (filterParams.match(noteEvent, it.relays)) return@filterTo true
|
if (filterParams.match(noteEvent, it.relays)) return@filterTo true
|
||||||
@@ -150,6 +152,23 @@ class NestsFeedFilter(
|
|||||||
starts < now + PLANNED_MAX_FUTURE_SECONDS
|
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
|
* Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room
|
||||||
* with no fresh kind-10312 presence carrying `onstage=1` published
|
* with no fresh kind-10312 presence carrying `onstage=1` published
|
||||||
@@ -204,6 +223,14 @@ class NestsFeedFilter(
|
|||||||
else -> null
|
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> {
|
override fun sort(items: Set<Note>): List<Note> {
|
||||||
val topFilter = account.liveNestsFollowLists.value
|
val topFilter = account.liveNestsFollowLists.value
|
||||||
val topFilterAuthors =
|
val topFilterAuthors =
|
||||||
@@ -221,30 +248,54 @@ class NestsFeedFilter(
|
|||||||
val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors
|
val followingKeySet = topFilterAuthors ?: account.kind3FollowList.flow.value.authors
|
||||||
|
|
||||||
val counter = ParticipantListBuilder()
|
val counter = ParticipantListBuilder()
|
||||||
val participantCounts = items.associate { it to counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
val live = ArrayList<Note>()
|
||||||
val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) }
|
val scheduled = ArrayList<Note>()
|
||||||
|
val ended = ArrayList<Note>()
|
||||||
|
|
||||||
return items
|
items.forEach {
|
||||||
.sortedWith(
|
when (bucketOf(it.event as? MeetingSpaceEvent)) {
|
||||||
compareBy(
|
NestBucket.LIVE -> live.add(it)
|
||||||
{ convertStatusToOrder(it.event as? MeetingSpaceEvent) },
|
NestBucket.SCHEDULED -> scheduled.add(it)
|
||||||
{ participantCounts[it] },
|
NestBucket.ENDED -> ended.add(it)
|
||||||
{ allParticipants[it] },
|
}
|
||||||
{ it.createdAt() },
|
|
||||||
{ it.idHex },
|
|
||||||
),
|
|
||||||
).reversed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertStatusToOrder(event: MeetingSpaceEvent?): Int =
|
// LIVE: follows-participating DESC, total participants DESC,
|
||||||
when (event?.status()) {
|
// createdAt DESC, idHex (stable tiebreak).
|
||||||
StatusTag.STATUS.LIVE -> 2
|
val followsParticipating = live.associateWith { counter.countFollowsThatParticipateOn(it, followingKeySet) }
|
||||||
StatusTag.STATUS.PRIVATE -> 1
|
val totalParticipating = live.associateWith { counter.countFollowsThatParticipateOn(it, null) }
|
||||||
StatusTag.STATUS.ENDED -> 0
|
live.sortWith(
|
||||||
else -> 0
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
enum class NestBucket {
|
||||||
|
LIVE,
|
||||||
|
SCHEDULED,
|
||||||
|
ENDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object Companion {
|
||||||
/**
|
/**
|
||||||
* Window inside which a kind-10312 presence event still counts
|
* Window inside which a kind-10312 presence event still counts
|
||||||
* an OPEN room as live. Same 10-minute cutoff NostrNests uses
|
* 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.
|
* future are likely spam or mis-set timestamps.
|
||||||
*/
|
*/
|
||||||
private const val PLANNED_MAX_FUTURE_SECONDS = 30L * 24L * 60L * 60L
|
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_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_unjoinable_back">Back</string>
|
||||||
<string name="nest_create_fab">Start space</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_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_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>
|
<string name="nest_no_server_use_default">Use default</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user