From 68f0ded7d5ebafc127b80056872ca62b4d45bdd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 00:45:01 +0000 Subject: [PATCH 1/8] feat: pin followed public chats to the top of the Public Chats feed --- .../ui/screen/loggedIn/publicChats/PublicChatsScreen.kt | 4 +++- .../loggedIn/publicChats/dal/PublicChatsFeedFilter.kt | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt index ae177d56d9..ff2155f1ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt @@ -106,8 +106,10 @@ fun WatchAccountForPublicChatsScreen( val hiddenUsers = accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() + val followedChats by accountViewModel.account.publicChatList.flowSet + .collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState, hiddenUsers) { + LaunchedEffect(accountViewModel, listState, hiddenUsers, followedChats) { publicChatsFeedState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt index f55e184e26..6932f2f88e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt @@ -32,7 +32,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel class PublicChatsFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code + override fun feedKey(): String = + account.userProfile().pubkeyHex + "-" + followList().code + "-" + + account.publicChatList.flowSet.value + .hashCode() override fun limit() = 200 @@ -105,6 +108,8 @@ class PublicChatsFeedFilter( } override fun sort(items: Set): List { + val followedSet = account.publicChatList.flowSet.value + val lastNote = items.associateWith { note -> LocalCache.getPublicChatChannelIfExists(note.idHex)?.lastNote?.createdAt() ?: 0L @@ -117,6 +122,8 @@ class PublicChatsFeedFilter( val comparator: Comparator = compareByDescending { + it.idHex in followedSet + }.thenByDescending { lastNote[it] }.thenByDescending { createdNote[it] From 3915e2b36f4dbf1d91e3fbf82c4eb6370a360d3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 02:20:40 +0000 Subject: [PATCH 2/8] refactor: render pinned followed chats as a separate LazyColumn section Instead of sorting followed chats to the top of the existing feed (which hides them when the feed filter or TopFilter excludes them), render them in a dedicated items{} block above the rest of the feed and skip them in the main itemsIndexed{} block. This keeps the feed filter, feedKey, and screen watchers untouched. --- .../publicChats/PublicChatsFeedLoaded.kt | 59 +++++++++++++++---- .../loggedIn/publicChats/PublicChatsScreen.kt | 4 +- .../publicChats/dal/PublicChatsFeedFilter.kt | 9 +-- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index 73872b432b..ce61cb95ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -23,14 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.LocalCache +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.screen.loggedIn.AccountViewModel @@ -47,30 +52,58 @@ fun PublicChatsFeedLoaded( nav: INav, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val followedSet by accountViewModel.account.publicChatList.flowSet + .collectAsStateWithLifecycle() + + val pinned = + remember(followedSet) { + followedSet.mapNotNull { idHex -> + LocalCache.getNoteIfExists(idHex)?.takeIf { it.event is ChannelCreateEvent } + } + } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { + items( + pinned, + key = { item -> "pinned-" + item.idHex }, + contentType = { item -> item.event?.kind ?: -1 }, + ) { item -> + PublicChatRow(item, accountViewModel, nav) + } + itemsIndexed( items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - Row(Modifier.fillMaxWidth().animateItem()) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = "PublicChatsFeed", - modifier = Modifier.fillMaxWidth(), - forceEventKind = ChannelCreateEvent.KIND, - accountViewModel = accountViewModel, - nav = nav, - ) + if (item.idHex !in followedSet) { + PublicChatRow(item, accountViewModel, nav) } - - HorizontalDivider( - thickness = DividerThickness, - ) } } } + +@Composable +private fun LazyItemScope.PublicChatRow( + item: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row(Modifier.fillMaxWidth().animateItem()) { + ChannelCardCompose( + baseNote = item, + routeForLastRead = "PublicChatsFeed", + modifier = Modifier.fillMaxWidth(), + forceEventKind = ChannelCreateEvent.KIND, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt index ff2155f1ef..ae177d56d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt @@ -106,10 +106,8 @@ fun WatchAccountForPublicChatsScreen( val hiddenUsers = accountViewModel.account.hiddenUsers.flow .collectAsStateWithLifecycle() - val followedChats by accountViewModel.account.publicChatList.flowSet - .collectAsStateWithLifecycle() - LaunchedEffect(accountViewModel, listState, hiddenUsers, followedChats) { + LaunchedEffect(accountViewModel, listState, hiddenUsers) { publicChatsFeedState.checkKeysInvalidateDataAndSendToTop() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt index 6932f2f88e..f55e184e26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/dal/PublicChatsFeedFilter.kt @@ -32,10 +32,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel class PublicChatsFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = - account.userProfile().pubkeyHex + "-" + followList().code + "-" + - account.publicChatList.flowSet.value - .hashCode() + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 200 @@ -108,8 +105,6 @@ class PublicChatsFeedFilter( } override fun sort(items: Set): List { - val followedSet = account.publicChatList.flowSet.value - val lastNote = items.associateWith { note -> LocalCache.getPublicChatChannelIfExists(note.idHex)?.lastNote?.createdAt() ?: 0L @@ -122,8 +117,6 @@ class PublicChatsFeedFilter( val comparator: Comparator = compareByDescending { - it.idHex in followedSet - }.thenByDescending { lastNote[it] }.thenByDescending { createdNote[it] From 13c0e26de3456b104d0f3751baade15e4ed98694 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 19:28:30 +0000 Subject: [PATCH 3/8] refactor: filter followed chats out of the main list before rendering Avoids creating empty LazyColumn item slots for followed channels that were skipped at render time. --- .../loggedIn/publicChats/PublicChatsFeedLoaded.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index ce61cb95ae..61d7133fd7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -62,6 +62,11 @@ fun PublicChatsFeedLoaded( } } + val unpinned = + remember(items.list, followedSet) { + items.list.filter { it.idHex !in followedSet } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, @@ -75,13 +80,11 @@ fun PublicChatsFeedLoaded( } itemsIndexed( - items.list, + unpinned, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - if (item.idHex !in followedSet) { - PublicChatRow(item, accountViewModel, nav) - } + PublicChatRow(item, accountViewModel, nav) } } } From ba43edef57a3f3b783dd3758cadd1faac4b0ddf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:45:31 +0000 Subject: [PATCH 4/8] feat: show pin icon on followed Public Chat cards Overlay a small push-pin glyph at the top-right of pinned channel cards so the user can tell at a glance why those rows are at the top. The underlying ChannelCardCompose is untouched; only the row wrapper changes to a Box to host the overlay. --- .../publicChats/PublicChatsFeedLoaded.kt | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index 61d7133fd7..1b4c35c847 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -20,19 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -42,6 +49,7 @@ 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.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @Composable @@ -76,7 +84,7 @@ fun PublicChatsFeedLoaded( key = { item -> "pinned-" + item.idHex }, contentType = { item -> item.event?.kind ?: -1 }, ) { item -> - PublicChatRow(item, accountViewModel, nav) + PublicChatRow(item, pinned = true, accountViewModel, nav) } itemsIndexed( @@ -84,7 +92,7 @@ fun PublicChatsFeedLoaded( key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> - PublicChatRow(item, accountViewModel, nav) + PublicChatRow(item, pinned = false, accountViewModel, nav) } } } @@ -92,10 +100,11 @@ fun PublicChatsFeedLoaded( @Composable private fun LazyItemScope.PublicChatRow( item: Note, + pinned: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { - Row(Modifier.fillMaxWidth().animateItem()) { + Box(Modifier.fillMaxWidth().animateItem()) { ChannelCardCompose( baseNote = item, routeForLastRead = "PublicChatsFeed", @@ -104,6 +113,18 @@ private fun LazyItemScope.PublicChatRow( accountViewModel = accountViewModel, nav = nav, ) + if (pinned) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) + } } HorizontalDivider( From 5cdf69bb8392334fcb008814274e1597c766f1ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:52:55 +0000 Subject: [PATCH 5/8] fix: move pin badge to cover-image corner and add section gap - Pin glyph moved from top-right (overlapping LikeReaction/ZapReaction) to top-left, inside a surface-tinted circular badge that overlays the cover image corner. Reads against any cover image and frees the reaction buttons from being eclipsed and hit-tested through. - 8dp Spacer item inserted between the pinned items{} and unpinned itemsIndexed{} blocks, only when both sides have content, so the section boundary reads as a section break instead of just another row. --- .../publicChats/PublicChatsFeedLoaded.kt | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index 1b4c35c847..8cc1a603c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -29,6 +31,7 @@ import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -49,7 +52,6 @@ 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.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent @Composable @@ -87,6 +89,12 @@ fun PublicChatsFeedLoaded( PublicChatRow(item, pinned = true, accountViewModel, nav) } + if (pinned.isNotEmpty() && unpinned.isNotEmpty()) { + item(key = "pinned-unpinned-gap", contentType = "section-gap") { + Box(Modifier.fillMaxWidth().height(8.dp)) + } + } + itemsIndexed( unpinned, key = { _, item -> item.idHex }, @@ -114,15 +122,11 @@ private fun LazyItemScope.PublicChatRow( nav = nav, ) if (pinned) { - Icon( - symbol = MaterialSymbols.PushPin, - contentDescription = null, + PinBadge( modifier = Modifier - .align(Alignment.TopEnd) - .padding(10.dp) - .size(16.dp), - tint = MaterialTheme.colorScheme.placeholderText, + .align(Alignment.TopStart) + .padding(start = 14.dp, top = 14.dp), ) } } @@ -131,3 +135,24 @@ private fun LazyItemScope.PublicChatRow( thickness = DividerThickness, ) } + +@Composable +private fun PinBadge(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .size(22.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.PushPin, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} From ee0b658401d09e5b1314332f505012432e14abef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:09:49 +0000 Subject: [PATCH 6/8] feat: keep Public Chats scrolled to top when new top items arrive When the first item of either the pinned or unpinned block changes, animate-scroll back to index 0 if the user was at or near the very top. This mirrors the pattern from ChatFeedView so a new chat bubbling up doesn't leave the user one row below it. --- .../screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index 8cc1a603c2..b15269c53b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -77,6 +78,12 @@ fun PublicChatsFeedLoaded( items.list.filter { it.idHex !in followedSet } } + LaunchedEffect(pinned.firstOrNull()?.idHex, unpinned.firstOrNull()?.idHex) { + if (listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } + } + LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, From 3f91cb16890aaf0defc313155b83e242fa8b3229 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 21:35:27 +0000 Subject: [PATCH 7/8] fix: actually fetch ChannelCreateEvent (kind 40) and widen the relay set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterMissingChannelsById had an inverted isEmpty() check that emitted zero filters, so kind 40 was never requested from any relay. Channels discovered from kind 42 messages stayed as empty stubs unless the creator also happened to publish a kind 41 metadata update findable on the same relay — which is why most cards in the Public Chats feed loaded with no name or picture. Fix: - Drop the inverted condition; mapOfSet guarantees non-empty values, so emit a RelayBasedFilter for every (relay, channelIds) entry. - Widen the relay set per channel to include the user's search and indexer relay lists. Falls back to DefaultSearchRelayList / DefaultIndexerRelayList when those lists are empty. - Plumb the Account through ChannelFinderQueryState so the assembler can read the search/indexer flows. Mirrors EventFinderQueryState. --- .../ChannelFinderFilterAssemblyGroup.kt | 2 + ...ChannelFinderFilterAssemblySubscription.kt | 8 ++-- .../FilterChannelMetadataCreationById.kt | 37 ++++++++++--------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 9ea5235108..3f12d0bd05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -31,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @Stable class ChannelFinderQueryState( val channel: Channel, + val account: Account, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt index 8d5f6828ac..3bc756a125 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblySubscription.kt @@ -24,24 +24,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable fun ChannelFinderFilterAssemblerSubscription( channel: Channel, accountViewModel: AccountViewModel, -) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.dataSources().channelFinder) +) = ChannelFinderFilterAssemblerSubscription(channel, accountViewModel.account, accountViewModel.dataSources().channelFinder) @Composable fun ChannelFinderFilterAssemblerSubscription( channel: Channel, + account: Account, dataSource: ChannelFinderFilterAssemblyGroup, ) { // different screens get different states // even if they are tracking the same tag. val state = - remember(channel) { - ChannelFinderQueryState(channel) + remember(channel, account) { + ChannelFinderQueryState(channel, account) } LifecycleAwareKeyDataSourceSubscription(state, dataSource) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt index f8c58f0910..c10fe0477e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/FilterChannelMetadataCreationById.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -34,29 +36,28 @@ fun filterMissingChannelsById(keys: List): List if (key.channel is PublicChatChannel && key.channel.event == null) { - key.channel.relays().forEach { + val searchRelays = + key.account.searchRelayList.flow.value + .ifEmpty { DefaultSearchRelayList } + val indexerRelays = + key.account.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList } + + (key.channel.relays() + searchRelays + indexerRelays).forEach { add(it, key.channel.idHex) } - } else { - null } } } - if (relayPerChannel.isEmpty()) return emptyList() - - return relayPerChannel.mapNotNull { - if (it.value.isEmpty()) { - RelayBasedFilter( - relay = it.key, - filter = - Filter( - kinds = filterMissingPublicChannelsByIdKinds, - ids = it.value.sorted(), - ), - ) - } else { - null - } + return relayPerChannel.map { (relay, channelIds) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = filterMissingPublicChannelsByIdKinds, + ids = channelIds.sorted(), + ), + ) } } From 585b28163a8ab865e78439cfb6efe0b930f0bcb4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 22 May 2026 18:25:07 -0400 Subject: [PATCH 8/8] Better rendering of Public Chats --- .../ui/components/SensitivityWarning.kt | 8 +- .../amethyst/ui/note/DisplayAuthorBanner.kt | 27 +++++- .../vitorpamplona/amethyst/ui/note/Loaders.kt | 9 +- .../ui/note/elements/DefaultImageHeader.kt | 40 ++++----- .../RenderPublicChatChannelThumb.kt | 5 +- .../publicChats/PublicChatsFeedLoaded.kt | 84 +++++++++++++++---- .../nip28PublicChats/PublicChatListState.kt | 14 ++++ 7 files changed, 133 insertions(+), 54 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index ddbb336cbc..fd8d7b7e29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -82,7 +82,13 @@ fun SensitivityWarning( accountViewModel: AccountViewModel, content: @Composable () -> Unit, ) { - note.event?.let { SensitivityWarning(it, accountViewModel, content) } + val noteEvent = note.event + + if (noteEvent == null) { + content() + } else { + SensitivityWarning(noteEvent, accountViewModel, content) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt index 337c19e403..67eb8ac256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/DisplayAuthorBanner.kt @@ -21,23 +21,44 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder @Composable fun DisplayAuthorBanner( - note: Note, + baseNote: Note, accountViewModel: AccountViewModel, modifier: Modifier = SimpleImageBorder, ) { - WatchAuthor(note, accountViewModel) { + val noteAuthor = baseNote.author + if (noteAuthor != null) { BannerImage( - it, + noteAuthor, modifier, accountViewModel, ) + } else { + val authorState by observeNote(baseNote, accountViewModel) + CrossfadeIfEnabled(authorState.note.author, accountViewModel = accountViewModel) { author -> + if (author != null) { + BannerImage( + author, + modifier, + accountViewModel, + ) + } else { + BannerImage( + null as String?, + modifier, + accountViewModel, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index 28e04659bb..a7e17d4536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -154,12 +154,11 @@ fun LoadPublicChatChannel( accountViewModel: AccountViewModel, content: @Composable (PublicChatChannel) -> Unit, ) { - val channel = - produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) { - value = accountViewModel.checkGetOrCreatePublicChatChannel(id) - } + val channel by produceStateIfNotNull(accountViewModel.getPublicChatChannelIfExists(id), id) { + value = accountViewModel.checkGetOrCreatePublicChatChannel(id) + } - channel.value?.let { content(it) } + channel?.let { content(it) } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt index dabcf9a69f..943c105290 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DefaultImageHeader.kt @@ -119,38 +119,32 @@ fun BannerImage( if (!banner.isNullOrBlank()) { MyAsyncImage( imageUrl = banner, - contentDescription = - stringRes( - R.string.preview_card_image_for, - banner, - ), + contentDescription = stringRes(R.string.preview_card_image_for, banner), contentScale = ContentScale.Crop, mainImageModifier = Modifier, loadedImageModifier = modifier, accountViewModel = accountViewModel, onLoadingBackground = { - Image( - painter = painterRes(R.drawable.profile_banner, 4), - contentDescription = stringRes(R.string.profile_banner), - contentScale = ContentScale.Crop, - modifier = modifier, - ) + DefaultProfileBanner(modifier, 4) }, onError = { - Image( - painter = painterRes(R.drawable.profile_banner, 4), - contentDescription = stringRes(R.string.profile_banner), - contentScale = ContentScale.Crop, - modifier = modifier, - ) + DefaultProfileBanner(modifier, 4) }, ) } else { - Image( - painter = painterRes(R.drawable.profile_banner, 5), - contentDescription = stringRes(R.string.profile_banner), - contentScale = ContentScale.Crop, - modifier = modifier, - ) + DefaultProfileBanner(modifier, 5) } } + +@Composable +fun DefaultProfileBanner( + modifier: Modifier, + sizeReference: Int, +) { + Image( + painter = painterRes(R.drawable.profile_banner, sizeReference), + contentDescription = stringRes(R.string.profile_banner), + contentScale = ContentScale.Crop, + modifier = modifier, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt index 2d2cc7f720..e5bab74702 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/RenderPublicChatChannelThumb.kt @@ -68,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.RowColSpacing import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -81,8 +80,6 @@ fun RenderPublicChatChannelThumb( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? ChannelCreateEvent ?: return - LoadPublicChatChannel(baseNote.idHex, accountViewModel) { RenderPublicChatChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav) } @@ -96,7 +93,7 @@ fun RenderPublicChatChannelThumb( nav: INav, ) { val channelUpdates by observeChannel(channel, accountViewModel) - val publicChat = channelUpdates?.channel as PublicChatChannel + val publicChat = (channelUpdates?.channel as? PublicChatChannel) ?: channel val name = remember(channelUpdates) { publicChat.toBestDisplayName() } val description = remember(channelUpdates) { publicChat.summary()?.ifBlank { null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt index b15269c53b..02e1a22882 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsFeedLoaded.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -45,15 +46,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport +import com.vitorpamplona.amethyst.ui.note.ClickableNote +import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent +import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.ChannelCardCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderPublicChatChannelThumb import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun PublicChatsFeedLoaded( @@ -66,12 +72,8 @@ fun PublicChatsFeedLoaded( val followedSet by accountViewModel.account.publicChatList.flowSet .collectAsStateWithLifecycle() - val pinned = - remember(followedSet) { - followedSet.mapNotNull { idHex -> - LocalCache.getNoteIfExists(idHex)?.takeIf { it.event is ChannelCreateEvent } - } - } + val pinned by accountViewModel.account.publicChatList.flowSetNote + .collectAsStateWithLifecycle() val unpinned = remember(items.list, followedSet) { @@ -91,7 +93,6 @@ fun PublicChatsFeedLoaded( items( pinned, key = { item -> "pinned-" + item.idHex }, - contentType = { item -> item.event?.kind ?: -1 }, ) { item -> PublicChatRow(item, pinned = true, accountViewModel, nav) } @@ -105,7 +106,6 @@ fun PublicChatsFeedLoaded( itemsIndexed( unpinned, key = { _, item -> item.idHex }, - contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> PublicChatRow(item, pinned = false, accountViewModel, nav) } @@ -114,20 +114,34 @@ fun PublicChatsFeedLoaded( @Composable private fun LazyItemScope.PublicChatRow( - item: Note, + baseNote: Note, pinned: Boolean, accountViewModel: AccountViewModel, nav: INav, ) { + val modifier = Modifier.fillMaxWidth() + Box(Modifier.fillMaxWidth().animateItem()) { - ChannelCardCompose( - baseNote = item, - routeForLastRead = "PublicChatsFeed", - modifier = Modifier.fillMaxWidth(), - forceEventKind = ChannelCreateEvent.KIND, + WatchNoteEvent( + baseNote = baseNote, accountViewModel = accountViewModel, - nav = nav, + onBlank = { + RenderChannel(baseNote, modifier, accountViewModel, nav) + }, + onNoteEventFound = { + CheckHiddenFeedWatchBlockAndReport( + note = baseNote, + modifier = modifier, + ignoreAllBlocksAndReports = false, + showHiddenWarning = false, + accountViewModel = accountViewModel, + nav = nav, + ) { _ -> + RenderChannel(baseNote, modifier, accountViewModel, nav) + } + }, ) + if (pinned) { PinBadge( modifier = @@ -143,6 +157,40 @@ private fun LazyItemScope.PublicChatRow( ) } +@Composable +private fun RenderChannel( + baseNote: Note, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + LongPressToQuickAction(baseNote, accountViewModel, nav) { showPopup -> + ClickableNote( + baseNote = baseNote, + backgroundColor = + calculateBackgroundColor( + createdAt = baseNote.createdAt(), + routeForLastRead = "PublicChatsFeed", + parentBackgroundColor = null, + accountViewModel = accountViewModel, + ), + modifier = modifier, + accountViewModel = accountViewModel, + showPopup = showPopup, + nav = nav, + ) { + Column(StdPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel, + ) { + RenderPublicChatChannelThumb(baseNote, accountViewModel, nav) + } + } + } + } +} + @Composable private fun PinBadge(modifier: Modifier = Modifier) { Box( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt index 2a676de5f9..3103bc76a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip28PublicChats/PublicChatListState.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -96,6 +97,19 @@ class PublicChatListState( emptySet(), ) + val flowSetNote = + flowSet + .map { + it.mapNotNull { + cache.checkGetOrCreateNote(it) + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + suspend fun follow(channel: PublicChatChannel): ChannelListEvent { val publicChatList = getChannelList()