mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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) }
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
|
||||
+106
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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,
|
||||
)
|
||||
}
|
||||
+132
@@ -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
|
||||
}
|
||||
}
|
||||
+14
-57
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user