mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+2
@@ -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
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+19
-18
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+17
-23
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-4
@@ -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 } }
|
||||
|
||||
+158
-21
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user