diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 4528457987..3e7ab3c069 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 69c66edcfc..3b43962176 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } composableFromEnd { NestsScreen(accountViewModel, nav) } + composableFromEnd { MeetingRoomsScreen(accountViewModel, nav) } composableFromEnd { LongsScreen(accountViewModel, nav) } composableFromEnd { ArticlesScreen(accountViewModel, nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 85bf299b70..c4d48f48e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -55,6 +55,7 @@ enum class NavBarItem { FOLLOW_PACKS, LIVE_STREAMS, NESTS, + MEETING_ROOMS, LONGS, POLLS, BADGES, @@ -219,6 +220,13 @@ val NavBarCatalog: Map = 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.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, if (isDebug) NavBarItem.NESTS else null, + if (isDebug) NavBarItem.MEETING_ROOMS else null, NavBarItem.LONGS, NavBarItem.POLLS, NavBarItem.BADGES, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index bf8742969a..0f575c4703 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -91,6 +91,8 @@ sealed class Route { @Serializable object Nests : Route() + @Serializable object MeetingRooms : Route() + @Serializable object Longs : Route() @Serializable object Articles : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 8c9e821b9e..1166c2ec68 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsFeedLoaded.kt new file mode 100644 index 0000000000..80fb586a8a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsFeedLoaded.kt @@ -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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsScreen.kt new file mode 100644 index 0000000000..8afe67486c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsScreen.kt @@ -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 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsTopBar.kt new file mode 100644 index 0000000000..7be825d9e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/MeetingRoomsTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/dal/MeetingRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/dal/MeetingRoomsFeedFilter.kt new file mode 100644 index 0000000000..c0585f5e09 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/meetingrooms/dal/MeetingRoomsFeedFilter.kt @@ -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() { + 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 { + val allNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } + return sort(innerApplyFilter(allNotes)) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List { + 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 + } +} 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 3decdc58cf..e213299012 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 @@ -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): List { 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 } - } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e18e41a0c2..24d3bd5b6d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -489,6 +489,7 @@ Follow Packs Live Streams Nests + Meeting Rooms Stage Audience Raise hand