From 1fd89b23e5cf5094d7bb1bb3c1ef93f7637d0b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 01:57:16 +0000 Subject: [PATCH] feat: audio rooms drawer listing (NIP-53 kind 30312) Adds a new "Audio Rooms" entry in the drawer feeds section that lists NIP-53 kind 30312 (Interactive Rooms / audio spaces) events, mirroring the existing Live Streams architecture. - AudioRoomsFeedFilter narrows LocalCache.liveChatChannels to MeetingSpaceEvent (30312) and MeetingRoomEvent (30313), sorting by OPEN > PRIVATE > CLOSED then by follow participation. - AudioRoomsFilterAssembler/SubAssembler reuse makeLiveActivitiesFilter so the wire-level REQs are shared with the Live Streams screen. - AudioRoomsScreen/TopBar/FeedLoaded follow the Live Streams layout and render via ChannelCardCompose. This is phase 1 of the Clubhouse/nests integration plan: it only adds the discovery surface. Joining, presence, audio transport (MoQ) and room creation will arrive in subsequent PRs. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D --- .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 10 ++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 4 + .../audiorooms/AudioRoomsFeedLoaded.kt | 74 +++++++++ .../loggedIn/audiorooms/AudioRoomsScreen.kt | 113 +++++++++++++ .../loggedIn/audiorooms/AudioRoomsTopBar.kt | 70 ++++++++ .../audiorooms/dal/AudioRoomsFeedFilter.kt | 149 ++++++++++++++++++ .../datasource/AudioRoomsFilterAssembler.kt | 50 ++++++ .../AudioRoomsFilterAssemblerSubscription.kt | 48 ++++++ .../datasource/AudioRoomsSubAssembler.kt | 103 ++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 14 files changed, 630 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index ef92b6ebb2..d942734929 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler @@ -105,6 +106,7 @@ class RelaySubscriptionsCoordinator( val shorts = ShortsFilterAssembler(client) val publicChats = PublicChatsFilterAssembler(client) val liveStreams = LiveStreamsFilterAssembler(client) + val audioRooms = AudioRoomsFilterAssembler(client) val longs = LongsFilterAssembler(client) val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) @@ -129,6 +131,7 @@ class RelaySubscriptionsCoordinator( publicChats, followPacksList, liveStreams, + audioRooms, longs, articles, badges, 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 207e2e11e9..fd105237f0 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 @@ -67,6 +67,7 @@ object ScrollStateKeys { const val PUBLIC_CHATS_SCREEN = "PublicChatsFeed" const val FOLLOW_PACKS_SCREEN = "FollowPacksFeed" const val LIVE_STREAMS_SCREEN = "LiveStreamsFeed" + const val AUDIO_ROOMS_SCREEN = "AudioRoomsFeed" 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 0ab6ef9715..f1ec9ca6ec 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.ArticlesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.AudioRoomsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.BadgesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.award.AwardBadgeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.ProfileBadgesScreen @@ -246,6 +247,7 @@ fun BuildNavigation( composableFromEnd { PublicChatsScreen(accountViewModel, nav) } composableFromEnd { FollowPacksScreen(accountViewModel, nav) } composableFromEnd { LiveStreamsScreen(accountViewModel, nav) } + composableFromEnd { AudioRoomsScreen(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 18e26e0a5c..c6bc946bd4 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 @@ -32,6 +32,7 @@ import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.EmojiEmotions import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.MilitaryTech import androidx.compose.material.icons.outlined.Photo import androidx.compose.material.icons.outlined.PlayCircle @@ -72,6 +73,7 @@ enum class NavBarItem { PUBLIC_CHATS, FOLLOW_PACKS, LIVE_STREAMS, + AUDIO_ROOMS, LONGS, POLLS, BADGES, @@ -240,6 +242,13 @@ val NavBarCatalog: Map = icon = NavBarIcon.Vector(Icons.Outlined.Sensors), resolveRoute = { Route.LiveStreams }, ), + NavBarItem.AUDIO_ROOMS to + NavBarItemDef( + id = NavBarItem.AUDIO_ROOMS, + labelRes = R.string.audio_rooms, + icon = NavBarIcon.Vector(Icons.Outlined.Mic), + resolveRoute = { Route.AudioRooms }, + ), NavBarItem.LONGS to NavBarItemDef( id = NavBarItem.LONGS, @@ -326,6 +335,7 @@ val DrawerFeedsItems: List = NavBarItem.PUBLIC_CHATS, NavBarItem.FOLLOW_PACKS, NavBarItem.LIVE_STREAMS, + NavBarItem.AUDIO_ROOMS, 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 f84f394bb5..7844f28fdc 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 @@ -89,6 +89,8 @@ sealed class Route { @Serializable object LiveStreams : Route() + @Serializable object AudioRooms : 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 6c5043e1a6..5c52c7b272 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.dal.ArticlesFeedFilter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.dal.AudioRoomsFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.dal.BadgesFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter @@ -100,6 +101,7 @@ class AccountFeedContentStates( val publicChatsFeed = FeedContentState(PublicChatsFeedFilter(account), scope, LocalCache) val followPacksFeed = FeedContentState(FollowPacksFeedFilter(account), scope, LocalCache) val liveStreamsFeed = FeedContentState(LiveStreamsFeedFilter(account), scope, LocalCache) + val audioRoomsFeed = FeedContentState(AudioRoomsFeedFilter(account), scope, LocalCache) val longsFeed = FeedContentState(LongsFeedFilter(account), scope, LocalCache) val articlesFeed = FeedContentState(ArticlesFeedFilter(account), scope, LocalCache) @@ -165,6 +167,7 @@ class AccountFeedContentStates( publicChatsFeed.updateFeedWith(newNotes) followPacksFeed.updateFeedWith(newNotes) liveStreamsFeed.updateFeedWith(newNotes) + audioRoomsFeed.updateFeedWith(newNotes) longsFeed.updateFeedWith(newNotes) articlesFeed.updateFeedWith(newNotes) @@ -211,6 +214,7 @@ class AccountFeedContentStates( publicChatsFeed.deleteFromFeed(newNotes) followPacksFeed.deleteFromFeed(newNotes) liveStreamsFeed.deleteFromFeed(newNotes) + audioRoomsFeed.deleteFromFeed(newNotes) longsFeed.deleteFromFeed(newNotes) articlesFeed.deleteFromFeed(newNotes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt new file mode 100644 index 0000000000..fabf59ea4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsFeedLoaded.kt @@ -0,0 +1,74 @@ +/* + * 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.audiorooms + +import androidx.compose.foundation.ExperimentalFoundationApi +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.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AudioRoomsFeedLoaded( + 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()) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = "AudioRoomsFeed", + modifier = Modifier.fillMaxWidth(), + forceEventKind = MeetingSpaceEvent.KIND, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt new file mode 100644 index 0000000000..dc7669506a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsScreen.kt @@ -0,0 +1,113 @@ +/* + * 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.audiorooms + +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.audiorooms.datasource.AudioRoomsFilterAssemblerSubscription + +@Composable +fun AudioRoomsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + AudioRoomsScreen( + audioRoomsFeedContentState = accountViewModel.feedStates.audioRoomsFeed, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun AudioRoomsScreen( + audioRoomsFeedContentState: FeedContentState, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(audioRoomsFeedContentState) + WatchAccountForAudioRoomsScreen(audioRoomsFeedState = audioRoomsFeedContentState, accountViewModel = accountViewModel) + AudioRoomsFilterAssemblerSubscription(accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + AudioRoomsTopBar(accountViewModel, nav) + }, + bottomBar = { + AppBottomBar(Route.AudioRooms, accountViewModel) { route -> + if (route == Route.AudioRooms) { + audioRoomsFeedContentState.sendToTop() + } else { + nav.newStack(route) + } + } + }, + accountViewModel = accountViewModel, + ) { + RefresheableBox(audioRoomsFeedContentState, true) { + SaveableFeedContentState(audioRoomsFeedContentState, scrollStateKey = ScrollStateKeys.AUDIO_ROOMS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = audioRoomsFeedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "AudioRoomsFeed", + onLoaded = { loaded -> + AudioRoomsFeedLoaded( + loaded = loaded, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) + } + } + } +} + +@Composable +fun WatchAccountForAudioRoomsScreen( + audioRoomsFeedState: FeedContentState, + accountViewModel: AccountViewModel, +) { + val listState by accountViewModel.account.liveLiveStreamsFollowLists.collectAsStateWithLifecycle() + val hiddenUsers = + accountViewModel.account.hiddenUsers.flow + .collectAsStateWithLifecycle() + + LaunchedEffect(accountViewModel, listState, hiddenUsers) { + audioRoomsFeedState.checkKeysInvalidateDataAndSendToTop() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.kt new file mode 100644 index 0000000000..c763158641 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/AudioRoomsTopBar.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.audiorooms + +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 AudioRoomsTopBar( + accountViewModel: AccountViewModel, + nav: INav, +) { + UserDrawerSearchTopBar(accountViewModel, nav) { + val list by accountViewModel.account.settings.defaultLiveStreamsFollowList + .collectAsStateWithLifecycle() + + AudioRoomsTopNavFilterBar( + followListsModel = accountViewModel.feedStates.feedListOptions, + listName = list, + accountViewModel = accountViewModel, + onChange = accountViewModel.account.settings::changeDefaultLiveStreamsFollowList, + ) + } +} + +@Composable +private fun AudioRoomsTopNavFilterBar( + 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(allLists.getOrNull(it) ?: followListsModel.allFollows) }, + accountViewModel = accountViewModel, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt new file mode 100644 index 0000000000..e38167b7bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/dal/AudioRoomsFeedFilter.kt @@ -0,0 +1,149 @@ +/* + * 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.audiorooms.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.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. + */ +class AudioRoomsFeedFilter( + 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 allRoomNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } + return sort(innerApplyFilter(allRoomNotes)) + } + + 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 + (noteEvent is MeetingSpaceEvent || noteEvent is MeetingRoomEvent) && + 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) }, + { 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: 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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt new file mode 100644 index 0000000000..7345e132e6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssembler.kt @@ -0,0 +1,50 @@ +/* + * 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.audiorooms.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +class AudioRoomsQueryState( + val account: Account, + val feedStates: AccountFeedContentStates, + val scope: CoroutineScope, +) + +@Stable +class AudioRoomsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + AudioRoomsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..9e536a8f1e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsFilterAssemblerSubscription.kt @@ -0,0 +1,48 @@ +/* + * 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.audiorooms.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun AudioRoomsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + AudioRoomsFilterAssemblerSubscription( + accountViewModel.dataSources().audioRooms, + accountViewModel, + ) +} + +@Composable +fun AudioRoomsFilterAssemblerSubscription( + dataSource: AudioRoomsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(accountViewModel.account) { + AudioRoomsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) + } + + KeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt new file mode 100644 index 0000000000..867e5e1f92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/AudioRoomsSubAssembler.kt @@ -0,0 +1,103 @@ +/* + * 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.audiorooms.datasource + +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.makeLiveActivitiesFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch + +/** + * Reuses `makeLiveActivitiesFilter`, which already subscribes to kinds + * 30311/30312/30313/1311. The feed filter narrows to 30312/30313 client-side; + * sharing the wire filter avoids duplicate REQs on relays when both the Live + * Streams and Audio Rooms screens are open for the same user. + */ +class AudioRoomsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: AudioRoomsQueryState, + since: SincePerRelayMap?, + ): List { + val feedSettings = key.followsPerRelay() + return makeLiveActivitiesFilter(feedSettings, since, key.feedStates.audioRoomsFeed.lastNoteCreatedAtIfFilled()) + } + + override fun user(key: AudioRoomsQueryState) = key.account.userProfile() + + override fun list(key: AudioRoomsQueryState) = key.listName() + + fun AudioRoomsQueryState.listNameFlow() = account.settings.defaultLiveStreamsFollowList + + fun AudioRoomsQueryState.listName() = listNameFlow().value + + fun AudioRoomsQueryState.followsPerRelayFlow() = account.liveLiveStreamsFollowListsPerRelay + + fun AudioRoomsQueryState.followsPerRelay() = followsPerRelayFlow().value + + private val userJobMap = mutableMapOf>() + + @OptIn(FlowPreview::class) + override fun newSub(key: AudioRoomsQueryState): Subscription { + val user = user(key) + userJobMap[user]?.forEach { it.cancel() } + userJobMap[user] = + listOf( + key.scope.launch(Dispatchers.IO) { + key.listNameFlow().collectLatest { + invalidateFilters() + } + }, + key.scope.launch(Dispatchers.IO) { + key.followsPerRelayFlow().sample(500).collectLatest { + invalidateFilters() + } + }, + key.account.scope.launch(Dispatchers.IO) { + key.feedStates.audioRoomsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, + ) + + return super.newSub(key) + } + + override fun endSub( + key: User, + subId: String, + ) { + super.endSub(key, subId) + userJobMap[key]?.forEach { it.cancel() } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 19796e0d84..02d939170a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -488,6 +488,7 @@ Public Chats Follow Packs Live Streams + Audio Rooms Videos Articles Private Bookmarks