Creates a feed for follow packs

This commit is contained in:
Vitor Pamplona
2025-11-10 17:02:21 -05:00
parent ffaa06fed7
commit bd1a70039a
24 changed files with 1174 additions and 169 deletions
@@ -1691,6 +1691,10 @@ class Account(
fun isFollowing(user: HexKey): Boolean = user in followingKeySet()
fun isKnown(user: User): Boolean = user.pubkeyHex in allFollows.flow.value.authors
fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true &&
// if user hasn't hided this author
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
@@ -77,6 +78,7 @@ class RelaySubscriptionsCoordinator(
val profile = UserProfileFilterAssembler(client)
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
@@ -149,6 +150,7 @@ fun AppNavigation(
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
@@ -65,73 +66,82 @@ fun routeFor(
fun routeFor(
noteEvent: Event,
loggedIn: Account,
): Route? {
): Route? =
if (noteEvent is DraftWrapEvent) {
val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent)
if (innerEvent is IsInPublicChatChannel) {
innerEvent.channelId()?.let {
return Route.PublicChatChannel(it)
}
} else if (innerEvent is LiveActivitiesEvent) {
innerEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (innerEvent is EphemeralChatEvent) {
innerEvent.roomId()?.let {
return Route.EphemeralChat(it.id, it.relayUrl.url)
}
} else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room)
} else if (innerEvent is AddressableEvent) {
return Route.Note(noteEvent.aTag().toTag())
if (innerEvent != null) {
routeForInner(innerEvent, loggedIn)
} else {
return Route.Note(noteEvent.id)
Route.Note(noteEvent.id)
}
} else if (noteEvent is AppDefinitionEvent) {
return Route.ContentDiscovery(noteEvent.id)
} else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channelId()?.let {
return Route.PublicChatChannel(it)
}
} else if (noteEvent is ChannelCreateEvent) {
return Route.PublicChatChannel(noteEvent.id)
} else if (noteEvent is LiveActivitiesEvent) {
noteEvent.address().let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activityAddress()?.let {
return Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
} else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room)
} else if (noteEvent is CommunityDefinitionEvent) {
return Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
} else if (noteEvent is GiftWrapEvent) {
noteEvent.innerEventId?.let {
return routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
} else if (noteEvent is SealedRumorEvent) {
noteEvent.innerEventId?.let {
return routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
} else if (noteEvent is AddressableEvent) {
return Route.Note(noteEvent.aTag().toTag())
} else {
return Route.Note(noteEvent.id)
routeForInner(noteEvent, loggedIn)
}
return null
}
fun routeForInner(
noteEvent: Event,
loggedIn: Account,
): Route? =
when (noteEvent) {
is AppDefinitionEvent -> {
Route.ContentDiscovery(noteEvent.id)
}
is IsInPublicChatChannel -> {
noteEvent.channelId()?.let {
Route.PublicChatChannel(it)
}
}
is ChannelCreateEvent -> {
Route.PublicChatChannel(noteEvent.id)
}
is LiveActivitiesEvent -> {
noteEvent.address().let {
Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
}
is LiveActivitiesChatMessageEvent -> {
noteEvent.activityAddress()?.let {
Route.LiveActivityChannel(it.kind, it.pubKeyHex, it.dTag)
}
}
is EphemeralChatEvent -> {
noteEvent.roomId()?.let {
Route.EphemeralChat(it.id, it.relayUrl.url)
}
}
is FollowListEvent -> {
Route.FollowPack(noteEvent.address())
}
is ChatroomKeyable -> {
val room = noteEvent.chatroomKey(loggedIn.userProfile().pubkeyHex)
loggedIn.chatroomList.getOrCreatePrivateChatroom(room)
Route.Room(room)
}
is CommunityDefinitionEvent -> {
Route.Community(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())
}
is GiftWrapEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
}
is SealedRumorEvent -> {
noteEvent.innerEventId?.let {
routeFor(LocalCache.getOrCreateNote(it), loggedIn)
}
}
is AddressableEvent -> {
Route.Note(noteEvent.aTag().toTag())
}
else -> {
Route.Note(noteEvent.id)
}
}
fun routeToMessage(
user: HexKey,
@@ -207,6 +217,8 @@ fun routeFor(roomId: RoomId): Route = Route.EphemeralChat(roomId.id, roomId.rela
fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex)
fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex)
fun authorRouteFor(note: Note): Route.Profile? = note.author?.pubkeyHex?.let { Route.Profile(it) }
fun routeReplyTo(
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.toRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Room
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.serialization.Serializable
@@ -96,7 +98,25 @@ sealed class Route {
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route()
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable data class FollowPack(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable data class PublicChatChannel(
val id: String,
@@ -708,7 +708,7 @@ private fun RenderNoteRow(
is BadgeAwardEvent -> RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav)
is FhirResourceEvent -> RenderFhirResource(baseNote, accountViewModel, nav)
is PeopleListEvent -> DisplayPeopleList(baseNote, backgroundColor, accountViewModel, nav)
is FollowListEvent -> DisplayFollowList(baseNote, backgroundColor, accountViewModel, nav)
is FollowListEvent -> DisplayFollowList(baseNote, accountViewModel, nav)
is RelaySetEvent -> DisplayRelaySet(baseNote, backgroundColor, accountViewModel, nav)
is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav)
is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
@@ -933,7 +933,7 @@ fun ObserveLikeText(
inner: @Composable (Int) -> Unit,
) {
val reactionCount by observeNoteReactionCount(baseNote, accountViewModel)
println("AABBCC $reactionCount ${baseNote.idHex}")
inner(reactionCount)
}
@@ -21,139 +21,163 @@
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.FollowSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.blackTagModifier
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayFollowList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FollowListEvent ?: return
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
var expanded by remember { mutableStateOf(false) }
val toMembersShow =
if (expanded) {
members
} else {
members.take(3)
val card =
observeNoteEventAndMap(baseNote, accountViewModel) { event: FollowListEvent? ->
if (event == null) {
FollowSetCard(
name = "",
media = "",
description = "",
users = persistentListOf(),
)
} else {
FollowSetCard(
name = event.title()?.ifBlank { null } ?: event.dTag(),
media = event.image()?.ifBlank { null },
description = event.description(),
users = accountViewModel.sortUsersSync(event.followIds()).toImmutableList(),
)
}
}
val image = noteEvent.image()
image?.let {
MyAsyncImage(
imageUrl = it,
contentDescription =
stringRes(
R.string.preview_card_image_for,
it,
),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = FollowSetImageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run {
DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier)
}
Text(
text = noteEvent.title() ?: noteEvent.dTag(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.padding(top = 10.dp),
textAlign = TextAlign.Center,
RenderFollowSetThumbEmbed(
card.value,
baseNote,
accountViewModel,
nav,
)
}
LaunchedEffect(Unit) {
accountViewModel.loadUsers(noteEvent.taggedUserIds()) {
members = it
}
}
@Composable
fun RenderFollowSetThumbEmbed(
card: FollowSetCard,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(
modifier =
Modifier.fillMaxWidth().clickable {
nav.nav { routeFor(baseNote, accountViewModel.account) }
},
verticalArrangement = SpacedBy5dp,
) {
Box(
contentAlignment = Alignment.BottomStart,
) {
card.media?.let {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier,
loadedImageModifier = FollowSetImageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
} ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) }
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
toMembersShow.forEach { user ->
Column(modifier = Modifier.fillMaxWidth()) {
UserCompose(
user,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
GalleryUnloaded(card.users, StdPadding, accountViewModel, nav)
}
if (members.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { expanded = !expanded }
}
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = card.name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringRes(R.string.follow_list_item_label),
color = MaterialTheme.colorScheme.background,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = MaterialTheme.colorScheme.blackTagModifier,
)
}
}
}
@Preview
@Composable
fun RenderFollowSetThumbPreview() {
val accountViewModel = mockAccountViewModel()
ThemeComparisonColumn {
RenderFollowSetThumbEmbed(
card =
FollowSetCard(
"Orange Pill Perú",
"https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg",
"Desc",
persistentListOf(
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
),
),
baseNote = Note(""),
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
}
}
@@ -38,6 +38,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -71,7 +72,7 @@ fun DisplayPeopleList(
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
var expanded by remember { mutableStateOf(false) }
var expanded by rememberSaveable { mutableStateOf(false) }
val toMembersShow =
if (expanded) {
@@ -1076,11 +1076,12 @@ class AccountViewModel(
}
}
fun sortUsersSync(hexList: List<HexKey>): List<HexKey> = hexList.sortedByDescending { account.isKnown(it) }
fun loadUsersSync(hexList: List<String>): List<User> =
hexList
.mapNotNull { hex -> checkGetOrCreateUser(hex) }
.sortedBy { account.isFollowing(it) }
.reversed()
.sortedByDescending { account.isKnown(it) }
suspend fun checkVideoIsOnline(videoUrl: String): Boolean =
withContext(Dispatchers.IO) {
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -40,12 +39,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.Gallery
import com.vitorpamplona.amethyst.ui.note.GalleryUnloaded
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -58,10 +56,11 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.FollowSetImageModifier
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -72,7 +71,7 @@ data class FollowSetCard(
val name: String,
val media: String?,
val description: String?,
val users: ImmutableList<User>,
val users: ImmutableList<HexKey>,
)
@Composable
@@ -90,7 +89,7 @@ fun RenderFollowSetThumb(
description = noteEvent?.description(),
users =
accountViewModel
.loadUsersSync(
.sortUsersSync(
noteEvent?.followIds() ?: emptyList(),
).toImmutableList(),
)
@@ -119,11 +118,11 @@ fun RenderFollowSetThumbPreview() {
"https://i.postimg.cc/GtDgGY5v/5062563795762785335.jpg",
"Desc",
persistentListOf(
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile(),
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
accountViewModel.userProfile().pubkeyHex,
),
),
baseNote = Note(""),
@@ -164,7 +163,7 @@ fun RenderFollowSetThumb(
)
} ?: run { DefaultImageHeader(baseNote, accountViewModel, FollowSetImageModifier) }
Gallery(card.users, Modifier.padding(Size10dp), accountViewModel, nav)
GalleryUnloaded(card.users, StdPadding, accountViewModel, nav)
}
Spacer(modifier = DoubleVertSpacer)
@@ -0,0 +1,313 @@
/**
* 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.followPacks.feed
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedConversationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackFeedNewThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.dal.FollowPackMembersUserFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FollowPackFeedScreen(
address: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address, accountViewModel) {
it?.let {
PrepareViewModelsFollowPackScreen(
note = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
fun PrepareViewModelsFollowPackScreen(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel =
viewModel(
key = note.idHex + "ConversationsFeedViewModel",
factory =
FollowPackFeedConversationsFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
val newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel =
viewModel(
key = note.idHex + "NewThreadFeedViewModel",
factory =
FollowPackFeedNewThreadFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
val membersFeedViewModel: FollowPackMembersUserFeedViewModel =
viewModel(
key = note.idHex + "MembersFeedViewModel",
factory =
FollowPackMembersUserFeedViewModel.Factory(
note,
accountViewModel.account,
),
)
FollowPackFeedScreen(note, newThreadFeedViewModel, conversationsFeedViewModel, membersFeedViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FollowPackFeedScreen(
note: AddressableNote,
newThreadFeedViewModel: FollowPackFeedNewThreadFeedViewModel,
conversationsFeedViewModel: FollowPackFeedConversationsFeedViewModel,
membersFeedViewModel: FollowPackMembersUserFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(newThreadFeedViewModel)
WatchLifecycleAndUpdateModel(conversationsFeedViewModel)
WatchLifecycleAndUpdateModel(membersFeedViewModel)
FollowPackFeedFilterAssemblerSubscription(note, accountViewModel)
val pagerState = rememberForeverPagerState(note.idHex + "FollowPackScreenPagerState") { 3 }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
Column {
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val modifier = Modifier.fillMaxWidth().height(TopBarSize + statusBarHeight)
Box(
modifier = modifier, // Adjust height as needed for your banner
) {
DisplayBanner(note, Modifier.fillMaxSize(), accountViewModel)
ShorterTopAppBar(
title = {
FollowPackHeader(note, accountViewModel, nav)
},
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = nav::popBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
}
},
actions = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy2dp) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
iconSizeModifier = Size18Modifier,
) {
nav.nav {
Route.Note(note.idHex)
}
}
Spacer(modifier = HalfHorzSpacer)
LikeReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
nav,
)
Spacer(modifier = HalfHorzSpacer)
ZapReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.onBackground,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = HalfHorzSpacer)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.6f), // Make TopAppBar background transparent
),
)
}
TabRow(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
modifier = TabRowHeight,
selectedTabIndex = pagerState.currentPage,
) {
val coroutineScope = rememberCoroutineScope()
Tab(
selected = pagerState.currentPage == 0,
text = { Text(text = stringRes(R.string.new_threads)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(text = stringRes(R.string.conversations)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
Tab(
selected = pagerState.currentPage == 2,
text = { Text(text = stringRes(R.string.members)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
)
}
}
},
accountViewModel = accountViewModel,
) {
HorizontalPager(
contentPadding = it,
state = pagerState,
) { page ->
when (page) {
0 ->
RefresheableFeedView(
newThreadFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
1 ->
RefresheableFeedView(
conversationsFeedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
2 ->
UserFeedView(
membersFeedViewModel,
accountViewModel,
nav,
)
}
}
}
}
@Composable
private fun DisplayBanner(
baseNote: AddressableNote,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
) {
val noteEvent by observeNoteEvent<FollowListEvent>(baseNote, accountViewModel)
noteEvent?.image()?.let {
AsyncImage(
model = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.Crop,
modifier = Modifier,
)
}
}
@Composable
fun FollowPackHeader(
baseNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent by observeNoteEvent<FollowListEvent>(baseNote, accountViewModel)
Text(
text = noteEvent?.title() ?: baseNote.dTag(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@@ -0,0 +1,118 @@
/**
* 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.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
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.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
class FollowPackFeedConversationsFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
override fun showHiddenKey(): Boolean =
account.liveHomeFollowLists.value is MutedAuthorsByOutboxTopNavFilter ||
account.liveHomeFollowLists.value is MutedAuthorsByProxyTopNavFilter
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
return sort(
LocalCache.notes.filterIntoSet { _, it ->
acceptableEvent(it, filterParams)
},
)
}
val followPackEvent = followPackNote.event as? FollowListEvent
val follows = followPackEvent?.followIdSet() ?: emptySet()
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
followLists =
if (account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = follows,
defaultRelays = account.defaultGlobalRelays.flow,
blockedRelays = account.blockedRelayList.flow,
)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = follows,
proxyRelays = account.proxyRelayList.flow.value,
)
},
hiddenUsers = account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
}
}
fun acceptableEvent(
event: Event?,
filterParams: FilterByListParams,
): Boolean =
(
event is TextNoteEvent ||
event is PollNoteEvent ||
event is ChannelMessageEvent ||
event is CommentEvent ||
event is VoiceReplyEvent ||
event is PublicMessageEvent ||
event is LiveActivitiesChatMessageEvent
) &&
filterParams.match(event)
fun acceptableEvent(
note: Note,
filterParams: FilterByListParams,
): Boolean = acceptableEvent(note.event, filterParams) && !note.isNewThread()
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* 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.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class FollowPackFeedConversationsFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(FollowPackFeedConversationsFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackFeedConversationsFeedViewModel(note, account) as T
}
}
@@ -0,0 +1,152 @@
/**
* 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.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
class FollowPackFeedNewThreadFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : AdditiveFeedFilter<Note>() {
companion object Companion {
val ADDRESSABLE_KINDS =
listOf(
AudioTrackEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
LongTextNoteEvent.KIND,
)
}
val followPackEvent = followPackNote.event as? FollowListEvent
val follows = followPackEvent?.followIdSet() ?: emptySet()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex
override fun showHiddenKey(): Boolean = false
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
followLists =
if (account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = follows,
defaultRelays = account.defaultGlobalRelays.flow,
blockedRelays = account.blockedRelayList.flow,
)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = follows,
proxyRelays = account.proxyRelayList.flow.value,
)
},
hiddenUsers = account.hiddenUsers.flow.value,
)
override fun feed(): List<Note> {
val filterParams = buildFilterParams(account)
val notes =
LocalCache.notes.filterIntoSet { _, note ->
// Avoids processing addressables twice.
(note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams)
}
val longFormNotes =
LocalCache.addressables.filterIntoSet(
kinds = ADDRESSABLE_KINDS,
) { _, note ->
acceptableEvent(note, filterParams)
}
return sort(notes + longFormNotes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val filterParams = buildFilterParams(account)
return collection.filterTo(HashSet()) {
acceptableEvent(it, filterParams)
}
}
private fun acceptableEvent(
it: Note,
filterParams: FilterByListParams,
): Boolean {
val noteEvent = it.event
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is RepostEvent ||
noteEvent is GenericRepostEvent ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
noteEvent is PollNoteEvent ||
noteEvent is HighlightEvent ||
noteEvent is InteractiveStoryPrologueEvent ||
noteEvent is CommentEvent ||
noteEvent is AudioTrackEvent ||
noteEvent is VoiceEvent ||
noteEvent is AudioHeaderEvent
) &&
filterParams.match(noteEvent, it.relays) &&
it.isNewThread()
}
override fun sort(items: Set<Note>): List<Note> =
items
.distinctBy {
if (it.event is RepostEvent || it.event is GenericRepostEvent) {
it.replyTo?.lastOrNull()?.idHex ?: it.idHex // only the most recent repost per feed.
} else {
it.idHex
}
}.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,40 @@
/**
* 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.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
class FollowPackFeedNewThreadFeedViewModel(
val note: AddressableNote,
val account: Account,
) : FeedViewModel(FollowPackFeedNewThreadFeedFilter(note, account)) {
class Factory(
val note: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackFeedNewThreadFeedViewModel(note, account) as T
}
}
@@ -0,0 +1,54 @@
/**
* 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.followPacks.feed.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache.checkGetOrCreateUser
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
class FollowPackMembersFeedFilter(
val followPackNote: AddressableNote,
val account: Account,
) : FeedFilter<User>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex
val cache: MutableMap<FollowListEvent, List<User>> = mutableMapOf()
override fun feed(): List<User> {
val followPackEvent = followPackNote.event as? FollowListEvent ?: return emptyList()
val previousList = cache[followPackEvent]
if (previousList != null) return previousList
val follows =
followPackEvent
.followIdSet()
.mapNotNull { hex -> checkGetOrCreateUser(hex) }
.filter { !account.isHidden(it) }
.sortedByDescending { account.isKnown(it) }
cache[followPackEvent] = follows
return follows
}
}
@@ -0,0 +1,40 @@
/**
* 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.followPacks.feed.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
class FollowPackMembersUserFeedViewModel(
val followPackNote: AddressableNote,
val account: Account,
) : UserFeedViewModel(FollowPackMembersFeedFilter(followPackNote, account)) {
class Factory(
val followPackNote: AddressableNote,
val account: Account,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = FollowPackMembersUserFeedViewModel(followPackNote, account) as T
}
}
@@ -0,0 +1,49 @@
/**
* 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.followPacks.feed.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to tags, even the same tag
class FollowPackFeedQueryState(
var followPack: AddressableNote,
var account: Account,
)
@Stable
class FollowPackFeedFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<FollowPackFeedQueryState>() {
val group =
listOf(
FollowPackFeedFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,42 @@
/**
* 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.followPacks.feed.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FollowPackFeedFilterAssemblerSubscription(
pack: AddressableNote,
accountViewModel: AccountViewModel,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(pack) {
FollowPackFeedQueryState(pack, accountViewModel.account)
}
KeyDataSourceSubscription(state, accountViewModel.dataSources().followPacks)
}
@@ -0,0 +1,68 @@
/**
* 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.followPacks.feed.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsByProxyTopNavFilter
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows.filterHomePostsByAuthors
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
class FollowPackFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<FollowPackFeedQueryState>,
) : SingleSubEoseManager<FollowPackFeedQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<FollowPackFeedQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (keys.isEmpty()) return emptyList()
return keys.flatMap {
val followPack = it.followPack.event
if (followPack is FollowListEvent) {
val filter =
if (it.account.proxyRelayList.flow.value
.isEmpty()
) {
AllUserFollowsByOutboxTopNavFilter(
authors = followPack.followIdSet(),
defaultRelays = it.account.defaultGlobalRelays.flow,
blockedRelays = it.account.blockedRelayList.flow,
).startValue(it.account.cache)
} else {
AllUserFollowsByProxyTopNavFilter(
authors = followPack.followIdSet(),
proxyRelays = it.account.proxyRelayList.flow.value,
).startValue(it.account.cache)
}
filterHomePostsByAuthors(filter, since, null, null)
} else {
emptyList()
}
}
}
override fun distinct(key: FollowPackFeedQueryState) = key.followPack.idHex
}
@@ -51,6 +51,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarSize
val Shapes =
Shapes(
@@ -82,6 +83,8 @@ val HalfVertSpacer = Modifier.height(2.dp)
val MinHorzSpacer = Modifier.width(1.dp)
val HalfHorzSpacer = Modifier.width(3.dp)
val StdHorzSpacer = Modifier.width(5.dp)
val StdVertSpacer = Modifier.height(5.dp)
@@ -374,3 +377,5 @@ val SpacedBy10dp = Arrangement.spacedBy(Size10dp)
val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp)
val Size50ModifierOffset10 = Modifier.size(50.dp).offset(y = (-10).dp)
val FollowPackHeaderModifier = Modifier.fillMaxWidth().height(TopBarSize)
@@ -273,6 +273,18 @@ val lightNewItemBubbleModifier =
.clip(shape = CircleShape)
.background(LightColorPalette.primary)
val darkBlackTagModifier =
Modifier
.clip(SmallestBorder)
.background(DarkColorPalette.onBackground)
.padding(horizontal = 5.dp)
val lightBlackTagModifier =
Modifier
.clip(SmallestBorder)
.background(LightColorPalette.onBackground)
.padding(horizontal = 5.dp)
val RichTextDefaults = RichTextStyle().resolveDefaults()
val MarkDownStyleOnDark =
@@ -474,6 +486,10 @@ val ColorScheme.largeProfilePictureModifier: Modifier
val ColorScheme.newItemBubbleModifier: Modifier
get() = if (isLight) lightNewItemBubbleModifier else darkNewItemBubbleModifier
@Suppress("ModifierFactoryExtensionFunction")
val ColorScheme.blackTagModifier: Modifier
get() = if (isLight) lightBlackTagModifier else darkBlackTagModifier
val chartLightColors =
VicoTheme(
candlestickCartesianLayerColors =
+3
View File
@@ -1350,4 +1350,7 @@
<string name="public_members_count">Public Members (%1$s)</string>
<string name="add_user_to_the_list">Add user to the list</string>
<string name="remove_user_from_the_list">Add user to the list</string>
<string name="follow_list_item_label">Follow Pack</string>
<string name="members">Members</string>
</resources>