Merge pull request #3037 from vitorpamplona/claude/pin-followed-chats-iSRJ9

feat: pin followed public chats to the top of the Public Chats feed
This commit is contained in:
Vitor Pamplona
2026-05-22 20:00:53 -04:00
committed by GitHub
10 changed files with 251 additions and 78 deletions
@@ -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
@@ -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)
@@ -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<ChannelFinderQueryState>): List<RelayBa
mapOfSet {
keys.forEach { key ->
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(),
),
)
}
}
@@ -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
@@ -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,
)
}
}
}
}
@@ -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
@@ -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,
)
}
@@ -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 } }
@@ -20,24 +20,46 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats
import androidx.compose.foundation.layout.Row
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
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.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
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.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(
@@ -47,30 +69,145 @@ fun PublicChatsFeedLoaded(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val followedSet by accountViewModel.account.publicChatList.flowSet
.collectAsStateWithLifecycle()
val pinned by accountViewModel.account.publicChatList.flowSetNote
.collectAsStateWithLifecycle()
val unpinned =
remember(items.list, followedSet) {
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,
) {
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,
)
}
items(
pinned,
key = { item -> "pinned-" + item.idHex },
) { item ->
PublicChatRow(item, pinned = true, accountViewModel, nav)
}
HorizontalDivider(
thickness = DividerThickness,
)
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 },
) { _, item ->
PublicChatRow(item, pinned = false, accountViewModel, nav)
}
}
}
@Composable
private fun LazyItemScope.PublicChatRow(
baseNote: Note,
pinned: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val modifier = Modifier.fillMaxWidth()
Box(Modifier.fillMaxWidth().animateItem()) {
WatchNoteEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
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 =
Modifier
.align(Alignment.TopStart)
.padding(start = 14.dp, top = 14.dp),
)
}
}
HorizontalDivider(
thickness = DividerThickness,
)
}
@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(
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,
)
}
}
@@ -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()