feat(meetingrooms): split kind:30313 into its own drawer screen

Nests (kind:30312) and Meetings (kind:30313) were sharing one drawer
entry / one feed; the only thing they have in common is being NIP-53
events. They have different lifecycles (audio rooms vs scheduled
meetings) and different status enums (open/private/closed/planned vs
live/planned/ended). Mixing them under the Nests label was confusing.

Split:

- MeetingRoomsFeedFilter (new) — narrows to MeetingRoomEvent (30313),
  with title-must-be-non-blank gate (mirrors the EGG-01-style minimum-
  fields rule the Nests feed applies). Sort by status (LIVE > PLANNED
  > ENDED), then participants among follows, then `starts` / created.
- NestsFeedFilter (existing) — narrowed to MeetingSpaceEvent (30312)
  only. Drops the dual-kind branches in `innerApplyFilter` and the
  parallel sub-room enum in `convertStatusToOrder`. Same EGG-01 gate
  (room/status/service/endpoint).

UI:

- New MeetingRoomsScreen / MeetingRoomsFeedLoaded / MeetingRoomsTopBar.
  Read-only — the app doesn't compose kind-30313 events locally; tap
  routes a card to the standard thread view via `routeFor`.
- Reuses the existing in-feed renderer
  (`note.types.RenderMeetingRoomEvent`) for visual consistency
  with the Note thread surface.
- Mounts NestsFilterAssemblerSubscription so the wire side stays a
  single REQ regardless of which screen the user opens first
  (`makeLiveActivitiesFilter` already covers 30311/30312/30313/1311
  in one filter).

Wiring:

- Route.MeetingRooms → MeetingRoomsScreen in AppNavigation.
- NavBarItem.MEETING_ROOMS in the catalog (Groups icon, label
  "Meeting Rooms"). Same isDebug gate as NESTS while the surface is
  still in development.
- meetingRoomsFeed in AccountFeedContentStates (mirroring nestsFeed
  for updateFeedWith / deleteFromFeed propagation).
- ScrollStateKeys.MEETING_ROOMS_SCREEN.
- New string `meeting_rooms` ("Meeting Rooms").

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 18:36:10 +00:00
parent 14953706b6
commit 4782b7b6ac
11 changed files with 471 additions and 57 deletions
@@ -68,6 +68,7 @@ object ScrollStateKeys {
const val FOLLOW_PACKS_SCREEN = "FollowPacksFeed"
const val LIVE_STREAMS_SCREEN = "LiveStreamsFeed"
const val NESTS_SCREEN = "NestsFeed"
const val MEETING_ROOMS_SCREEN = "MeetingRoomsFeed"
const val LONGS_SCREEN = "LongsFeed"
const val ARTICLES_SCREEN = "ArticlesFeed"
@@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.metadata.PeopleL
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.FollowListAndPackAndUserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.LiveStreamsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.LongsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms.MeetingRoomsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListSelectUserScreen
@@ -246,6 +247,7 @@ fun BuildNavigation(
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.Nests> { NestsScreen(accountViewModel, nav) }
composableFromEnd<Route.MeetingRooms> { MeetingRoomsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -55,6 +55,7 @@ enum class NavBarItem {
FOLLOW_PACKS,
LIVE_STREAMS,
NESTS,
MEETING_ROOMS,
LONGS,
POLLS,
BADGES,
@@ -219,6 +220,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Mic,
resolveRoute = { Route.Nests },
),
NavBarItem.MEETING_ROOMS to
NavBarItemDef(
id = NavBarItem.MEETING_ROOMS,
labelRes = R.string.meeting_rooms,
icon = MaterialSymbols.Groups,
resolveRoute = { Route.MeetingRooms },
),
NavBarItem.LONGS to
NavBarItemDef(
id = NavBarItem.LONGS,
@@ -306,6 +314,7 @@ val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
if (isDebug) NavBarItem.NESTS else null,
if (isDebug) NavBarItem.MEETING_ROOMS else null,
NavBarItem.LONGS,
NavBarItem.POLLS,
NavBarItem.BADGES,
@@ -91,6 +91,8 @@ sealed class Route {
@Serializable object Nests : Route()
@Serializable object MeetingRooms : Route()
@Serializable object Longs : Route()
@Serializable object Articles : Route()
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.dal.LiveStreamsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.dal.LongsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms.dal.MeetingRoomsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSummaryState
@@ -102,6 +103,7 @@ class AccountFeedContentStates(
val followPacksFeed = FeedContentState(FollowPacksFeedFilter(account), scope, LocalCache)
val liveStreamsFeed = FeedContentState(LiveStreamsFeedFilter(account), scope, LocalCache)
val nestsFeed = FeedContentState(NestsFeedFilter(account), scope, LocalCache)
val meetingRoomsFeed = FeedContentState(MeetingRoomsFeedFilter(account), scope, LocalCache)
val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache)
val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache)
@@ -169,6 +171,7 @@ class AccountFeedContentStates(
followPacksFeed.updateFeedWith(newNotes)
liveStreamsFeed.updateFeedWith(newNotes)
nestsFeed.updateFeedWith(newNotes)
meetingRoomsFeed.updateFeedWith(newNotes)
longsFeed.updateFeedWith(newNotes)
articlesFeed.updateFeedWith(newNotes)
@@ -216,6 +219,7 @@ class AccountFeedContentStates(
followPacksFeed.deleteFromFeed(newNotes)
liveStreamsFeed.deleteFromFeed(newNotes)
nestsFeed.deleteFromFeed(newNotes)
meetingRoomsFeed.deleteFromFeed(newNotes)
longsFeed.deleteFromFeed(newNotes)
articlesFeed.deleteFromFeed(newNotes)
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
/**
* Read-only list of NIP-53 kind 30313 [MeetingRoomEvent]s. Each row
* uses the existing [RenderMeetingRoomEvent] thumbnail composable
* for visual parity with the in-feed render; tapping a row opens
* the standard thread view (`routeFor(note, account)`) so the user
* can see the meeting's full details, participants, and any
* `streaming` URL.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MeetingRoomsFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
MeetingRoomFeedCard(
baseNote = item,
modifier = Modifier.fillMaxWidth(),
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
@Composable
private fun MeetingRoomFeedCard(
baseNote: Note,
modifier: Modifier,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.event !is MeetingRoomEvent) return
Column(
modifier =
modifier.clickable {
nav.nav { routeFor(baseNote, accountViewModel.account) }
},
) {
Column(StdPadding) {
RenderMeetingRoomEvent(baseNote, accountViewModel, nav)
}
}
}
@@ -0,0 +1,130 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssemblerSubscription
/**
* Drawer screen for NIP-53 kind 30313 (Meeting). Reads from the
* `meetingRoomsFeed` content state which scans
* `LocalCache.liveChatChannels` for `MeetingRoomEvent`s; the wire
* subscription is the same `makeLiveActivitiesFilter` the Nests
* screen uses (it requests kinds 30311/30312/30313/1311 in one
* REQ), so mounting the `NestsFilterAssemblerSubscription` here
* keeps wire traffic identical whichever screen the user lands
* on first.
*
* Read-only list the app doesn't compose kind-30313 events
* locally. The per-row CTA (Join, recording link, etc.) lives
* inside [com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent].
*/
@Composable
fun MeetingRoomsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
MeetingRoomsScreen(
meetingRoomsFeedContentState = accountViewModel.feedStates.meetingRoomsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun MeetingRoomsScreen(
meetingRoomsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(meetingRoomsFeedContentState)
WatchAccountForMeetingRoomsScreen(meetingRoomsFeedState = meetingRoomsFeedContentState, accountViewModel = accountViewModel)
NestsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
MeetingRoomsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.MeetingRooms, accountViewModel) { route ->
if (route == Route.MeetingRooms) {
meetingRoomsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
}
}
},
accountViewModel = accountViewModel,
) {
RefresheableBox(meetingRoomsFeedContentState, true) {
SaveableFeedContentState(meetingRoomsFeedContentState, scrollStateKey = ScrollStateKeys.MEETING_ROOMS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = meetingRoomsFeedContentState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "MeetingRoomsFeed",
onLoaded = { loaded ->
MeetingRoomsFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
}
}
@Composable
fun WatchAccountForMeetingRoomsScreen(
meetingRoomsFeedState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveLiveStreamsFollowLists.collectAsStateWithLifecycle()
// Use `by` to unwrap the StateFlow's value into the LaunchedEffect
// key — without it, the State<T> object is reference-stable across
// recompositions and the effect never re-fires when the user
// mutes someone, leaving the feed stale until a manual refresh.
val hiddenUsers by accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
meetingRoomsFeedState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun MeetingRoomsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultLiveStreamsFollowList
.collectAsStateWithLifecycle()
MeetingRoomsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultLiveStreamsFollowList,
)
}
}
@Composable
private fun MeetingRoomsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = onChange,
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag
/**
* Drawer feed for NIP-53 kind 30313 (Meeting). Pulls
* [MeetingRoomEvent]s out of `LocalCache.liveChatChannels` which is
* the same backing store the Live Streams and Nests feeds walk
* and presents them in their own drawer entry so the audio-rooms
* surface (kind 30312) doesn't mix scheduled meetings into its list.
*
* Required-field gate: a kind-30313 with a blank title is
* unrenderable, so it's skipped at the feed level same UX rule
* the Nests feed applies to its own missing-fields case.
*/
class MeetingRoomsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code
override fun limit() = 50
fun followList(): TopFilter = account.settings.defaultLiveStreamsFollowList.value
private fun TopFilter.isMuteList() = this is TopFilter.MuteList
private fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
private fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val allNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) }
return sort(innerApplyFilter(allNotes))
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams =
FilterByListParams.create(
followLists = account.liveLiveStreamsFollowLists.value,
hiddenUsers = account.hiddenUsers.flow.value,
)
return collection.filterTo(HashSet()) {
val noteEvent = it.event as? MeetingRoomEvent ?: return@filterTo false
!noteEvent.title().isNullOrBlank() && filterParams.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> {
val topFilter = account.liveLiveStreamsFollowLists.value
val topFilterAuthors =
when (topFilter) {
is AuthorsByOutboxTopNavFilter -> topFilter.authors
is MutedAuthorsByOutboxTopNavFilter -> topFilter.authors
is AllFollowsByOutboxTopNavFilter -> topFilter.authors
is SingleCommunityTopNavFilter -> topFilter.authors
is AuthorsByProxyTopNavFilter -> topFilter.authors
is MutedAuthorsByProxyTopNavFilter -> topFilter.authors
is AllFollowsByProxyTopNavFilter -> topFilter.authors
else -> null
}
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) }
return items
.sortedWith(
compareBy(
{ convertStatusToOrder(it.event as? MeetingRoomEvent) },
{ participantCounts[it] },
{ allParticipants[it] },
{
when (val e = it.event) {
is MeetingRoomEvent -> e.starts() ?: it.createdAt()
else -> it.createdAt()
}
},
{ it.idHex },
),
).reversed()
}
private fun convertStatusToOrder(event: MeetingRoomEvent?): Int =
when (event?.status()) {
StatusTag.STATUS.LIVE -> 2
StatusTag.STATUS.PLANNED -> 1
StatusTag.STATUS.ENDED -> 0
else -> 0
}
}
@@ -34,16 +34,16 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
/**
* Drawer feed for NIP-53 kind 30312 (Interactive Rooms / audio spaces).
*
* Shares LocalCache.liveChatChannels with the Live Streams feed, but narrows
* to MeetingSpaceEvent (30312) and MeetingRoomEvent (30313) so that the
* Clubhouse-style audio-room surface is independent of video live streams.
* Shares LocalCache.liveChatChannels with the Live Streams feed, narrowed
* to MeetingSpaceEvent (30312) so the Clubhouse-style audio-room surface
* is independent of video live streams. Kind 30313 (NIP-53 meetings) lives
* in a sibling drawer entry see [com.vitorpamplona.amethyst.ui.screen.loggedIn.meetingrooms.dal.MeetingRoomsFeedFilter].
*/
class NestsFeedFilter(
val account: Account,
@@ -77,20 +77,8 @@ class NestsFeedFilter(
)
return collection.filterTo(HashSet()) {
val noteEvent = it.event ?: return@filterTo false
when (noteEvent) {
is MeetingSpaceEvent -> {
hasMinimumNestFields(noteEvent) && filterParams.match(noteEvent, it.relays)
}
is MeetingRoomEvent -> {
hasMinimumSubRoomFields(noteEvent) && filterParams.match(noteEvent, it.relays)
}
else -> {
false
}
}
val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false
hasMinimumNestFields(noteEvent) && filterParams.match(noteEvent, it.relays)
}
}
@@ -111,13 +99,6 @@ class NestsFeedFilter(
!event.service().isNullOrBlank() &&
!event.endpoint().isNullOrBlank()
/**
* Sub-room (kind:30313) gate. The interactive-room concept inside a
* meeting space needs at least a title to render meaningfully a
* sub-room with no title is unrenderable in a list.
*/
private fun hasMinimumSubRoomFields(event: MeetingRoomEvent): Boolean = !event.title().isNullOrBlank()
override fun sort(items: Set<Note>): List<Note> {
val topFilter = account.liveLiveStreamsFollowLists.value
val topFilterAuthors =
@@ -141,44 +122,20 @@ class NestsFeedFilter(
return items
.sortedWith(
compareBy(
{ convertStatusToOrder(it.event) },
{ convertStatusToOrder(it.event as? MeetingSpaceEvent) },
{ participantCounts[it] },
{ allParticipants[it] },
{
when (val e = it.event) {
is MeetingRoomEvent -> e.starts() ?: it.createdAt()
else -> it.createdAt()
}
},
{ it.createdAt() },
{ it.idHex },
),
).reversed()
}
private fun convertStatusToOrder(event: com.vitorpamplona.quartz.nip01Core.core.Event?): Int {
if (event == null) return 0
return when (event) {
is MeetingSpaceEvent -> {
when (event.status()) {
StatusTag.STATUS.OPEN -> 2
StatusTag.STATUS.PRIVATE -> 1
StatusTag.STATUS.CLOSED -> 0
else -> 0
}
}
is MeetingRoomEvent -> {
when (event.status()) {
com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.LIVE -> 2
com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.PLANNED -> 1
com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag.STATUS.ENDED -> 0
else -> 0
}
}
else -> {
0
}
private fun convertStatusToOrder(event: MeetingSpaceEvent?): Int =
when (event?.status()) {
StatusTag.STATUS.OPEN -> 2
StatusTag.STATUS.PRIVATE -> 1
StatusTag.STATUS.CLOSED -> 0
else -> 0
}
}
}
+1
View File
@@ -489,6 +489,7 @@
<string name="follow_packs">Follow Packs</string>
<string name="live_streams">Live Streams</string>
<string name="nests">Nests</string>
<string name="meeting_rooms">Meeting Rooms</string>
<string name="nest_stage">Stage</string>
<string name="nest_audience">Audience</string>
<string name="nest_raise_hand">Raise hand</string>