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 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(),
) )
} }
@@ -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
}
} }
} }
+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_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>