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:
Claude
2026-05-01 21:09:01 +00:00
parent e6d755a7ab
commit 1ce0e3c179
3 changed files with 375 additions and 58 deletions
@@ -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(),
)
}
@@ -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
}
}
}
+4
View File
@@ -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>