From 1ce0e3c17959ef43ba541a3bb18ded9bee1b8983 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 21:09:01 +0000 Subject: [PATCH] feat(nests): bucket feed into Live / Scheduled / Recently ended sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../screen/loggedIn/nests/NestsFeedLoaded.kt | 312 +++++++++++++++--- .../loggedIn/nests/dal/NestsFeedFilter.kt | 117 +++++-- amethyst/src/main/res/values/strings.xml | 4 + 3 files changed, 375 insertions(+), 58 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index c396b5df2..9cdbb36f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -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() + val scheduled = ArrayList() + val ended = ArrayList() + 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, + val scheduled: ImmutableList, + val ended: ImmutableList, +) + +@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, + 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, + 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(), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index 2564a2311..204e9138e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -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): List { 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() + val scheduled = ArrayList() + val ended = ArrayList() - 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 { 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 { (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 { 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 + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index caf123df0..af87760fa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -596,6 +596,10 @@ The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect. Back Start space + Live now + Scheduled + Recently ended + Ended %1$s ago Set up a nest server 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. Use default