Merge pull request #2494 from vitorpamplona/claude/clubhouse-event-listing-Kta46

Add MoQ-transport client and audio rooms support
This commit is contained in:
Vitor Pamplona
2026-04-22 09:50:02 -04:00
committed by GitHub
59 changed files with 6802 additions and 1 deletions
+1
View File
@@ -247,6 +247,7 @@ dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation project(path: ':ammolite')
implementation project(path: ':nestsClient')
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
@@ -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,
@@ -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"
@@ -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<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.FollowPacks> { FollowPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.LiveStreams> { LiveStreamsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioRooms> { AudioRoomsScreen(accountViewModel, nav) }
composableFromEnd<Route.Longs> { LongsScreen(accountViewModel, nav) }
composableFromEnd<Route.Articles> { ArticlesScreen(accountViewModel, nav) }
composableFromEnd<Route.NewHlsVideo> { NewHlsVideoScreen(accountViewModel, nav) }
@@ -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<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.FOLLOW_PACKS,
NavBarItem.LIVE_STREAMS,
NavBarItem.AUDIO_ROOMS,
NavBarItem.LONGS,
NavBarItem.POLLS,
NavBarItem.BADGES,
@@ -545,7 +545,12 @@ fun ListContent(
Column(modifier) {
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
// Audio rooms are debug-only while the WebTransport audio backend is
// unimplemented (see docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md).
// The Nostr-side of the feature (presence, hand-raise, chat) works, but
// without audio playback the entry is confusing to release-build users.
val feedsItems = if (isDebug) DrawerFeedsItems else DrawerFeedsItems.filter { it != NavBarItem.AUDIO_ROOMS }
CatalogSection(R.string.drawer_section_feeds, feedsItems, accountViewModel, nav)
CollapsibleSection(title = R.string.drawer_section_create) {
NavigationRow(
@@ -89,6 +89,8 @@ sealed class Route {
@Serializable object LiveStreams : Route()
@Serializable object AudioRooms : Route()
@Serializable object Longs : Route()
@Serializable object Articles : Route()
@@ -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)
@@ -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,
)
}
}
}
@@ -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()
}
}
@@ -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,
)
}
@@ -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<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 allRoomNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) }
return sort(innerApplyFilter(allRoomNotes))
}
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
(noteEvent is MeetingSpaceEvent || noteEvent is MeetingRoomEvent) &&
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) },
{ 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
}
}
}
}
@@ -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<AudioRoomsQueryState>() {
val group =
listOf(
AudioRoomsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -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)
}
@@ -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<AudioRoomsQueryState>,
) : PerUserAndFollowListEoseManager<AudioRoomsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: AudioRoomsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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<User, List<Job>>()
@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() }
}
}
@@ -0,0 +1,243 @@
/*
* 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.room
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PanTool
import androidx.compose.material.icons.outlined.PanTool
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Clubhouse-style audio-room "stage" rendered in place of the video player when
* the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent].
*
* Current (shipping) scope pure Nostr, no audio transport:
* - Displays host / speaker / audience avatars parsed from the 30312 `p` tags.
* - Publishes kind 10312 presence on enter and every 30 s while composed.
* - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event
* so a host on any NIP-53 client (browser, other Android, etc.) can see the
* request and promote the user to speaker.
*
* Audio playback / capture + the "Audio connected" chip + the mute button live
* behind the WebTransport + QUIC work tracked in
* `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md`. They're not
* exposed in the UI until that transport actually runs a chip that always
* reads "Failed: NotImplemented" and a mute button with no mic to mute would
* mislead users. Re-enabling them is a small UI patch once
* `QuicWebTransportFactory.connect()` produces a real session.
*/
@Composable
fun AudioRoomStage(
baseChannel: LiveActivitiesChannel,
accountViewModel: AccountViewModel,
) {
LoadAddressableNote(baseChannel.address, accountViewModel) { addressableNote ->
addressableNote ?: return@LoadAddressableNote
val event = addressableNote.event as? MeetingSpaceEvent ?: return@LoadAddressableNote
AudioRoomStageContent(event, accountViewModel)
}
}
@Composable
private fun AudioRoomStageContent(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
) {
val participants = remember(event) { event.participants() }
val hosts = remember(participants) { participants.filter { it.role.equals(ROLE.HOST.code, true) } }
val speakers = remember(participants) { participants.filter { it.role.equals(ROLE.SPEAKER.code, true) } }
val audience =
remember(participants) {
participants.filter {
!it.role.equals(ROLE.HOST.code, true) &&
!it.role.equals(ROLE.SPEAKER.code, true)
}
}
var handRaised by rememberSaveable(event.address().toValue()) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val account = accountViewModel.account
// Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed.
LaunchedEffect(event.address().toValue(), handRaised) {
publishPresence(account, event, handRaised)
while (isActive) {
delay(PRESENCE_REFRESH_MS)
publishPresence(account, event, handRaised)
}
}
// Best-effort "leave" — re-publish a lowered-hand presence so peers see us
// drop sooner than the 30 s heartbeat would otherwise allow.
DisposableEffect(event.address().toValue()) {
onDispose {
scope.launch(Dispatchers.IO) {
runCatching { publishPresence(account, event, handRaised = false) }
}
}
}
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
event.room()?.let {
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
)
}
event.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hosts.isNotEmpty() || speakers.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
people = hosts + speakers,
avatarSize = Size40dp,
accountViewModel = accountViewModel,
)
}
if (audience.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_audience),
people = audience,
avatarSize = Size35dp,
accountViewModel = accountViewModel,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(onClick = { handRaised = !handRaised }) {
Icon(
imageVector = if (handRaised) Icons.Filled.PanTool else Icons.Outlined.PanTool,
contentDescription =
stringRes(
if (handRaised) R.string.audio_room_lower_hand else R.string.audio_room_raise_hand,
),
)
}
}
}
}
}
@Composable
private fun StagePeopleRow(
label: String,
people: List<ParticipantTag>,
avatarSize: androidx.compose.ui.unit.Dp,
accountViewModel: AccountViewModel,
) {
Column(modifier = Modifier.padding(top = 8.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyRow(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(items = people, key = { it.pubKey }) {
ClickableUserPicture(
baseUserHex = it.pubKey,
size = avatarSize,
accountViewModel = accountViewModel,
)
}
}
}
}
private const val PRESENCE_REFRESH_MS = 30_000L
private suspend fun publishPresence(
account: com.vitorpamplona.amethyst.model.Account,
event: MeetingSpaceEvent,
handRaised: Boolean,
) {
runCatching {
account.signAndComputeBroadcast(
MeetingRoomPresenceEvent.build(
root = event,
handRaised = handRaised,
// muted tag intentionally omitted while audio transport is not
// shipping — we're not producing a mic stream so any muted
// value would be misleading.
muted = null,
),
)
}
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.room.AudioRoomStage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssemblerSubscription
@@ -129,6 +130,7 @@ fun LiveActivityChannelView(
.weight(1f, true),
) {
ShowVideoStreaming(channel, accountViewModel)
AudioRoomStage(channel, accountViewModel)
LiveStreamTopZappers(channel, accountViewModel, nav)
LiveStreamGoalHeader(channel, accountViewModel, nav)
RefreshingChatroomFeedView(
+5
View File
@@ -488,6 +488,11 @@
<string name="public_chats">Public Chats</string>
<string name="follow_packs">Follow Packs</string>
<string name="live_streams">Live Streams</string>
<string name="audio_rooms">Audio Rooms</string>
<string name="audio_room_stage">Stage</string>
<string name="audio_room_audience">Audience</string>
<string name="audio_room_raise_hand">Raise hand</string>
<string name="audio_room_lower_hand">Lower hand</string>
<string name="longs">Videos</string>
<string name="articles">Articles</string>
<string name="private_bookmarks">Private Bookmarks</string>