diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 25b8f346c..dd32e14ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 88c902298..0265f6603 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b35f388b0..1fe313d0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { GeoHashScreen(it, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index c69bc5e63..374c68d92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fa30ee8b2..4341b4644 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 6e9f9fa58..9650dbc61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 20560510c..1ed163a43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -933,7 +933,7 @@ fun ObserveLikeText( inner: @Composable (Int) -> Unit, ) { val reactionCount by observeNoteReactionCount(baseNote, accountViewModel) - + println("AABBCC $reactionCount ${baseNote.idHex}") inner(reactionCount) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt index ec18a8c73..969e04b2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FollowList.kt @@ -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, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = baseNote.event as? FollowListEvent ?: return - - var members by remember { mutableStateOf>(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(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt index 45d867a2d..8d818b56c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PeopleList.kt @@ -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>(persistentListOf()) } - var expanded by remember { mutableStateOf(false) } + var expanded by rememberSaveable { mutableStateOf(false) } val toMembersShow = if (expanded) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4f86b1000..46085c9b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1076,11 +1076,12 @@ class AccountViewModel( } } + fun sortUsersSync(hexList: List): List = hexList.sortedByDescending { account.isKnown(it) } + fun loadUsersSync(hexList: List): List = hexList .mapNotNull { hex -> checkGetOrCreateUser(hex) } - .sortedBy { account.isFollowing(it) } - .reversed() + .sortedByDescending { account.isKnown(it) } suspend fun checkVideoIsOnline(videoUrl: String): Boolean = withContext(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt index 29bc8d3f9..2d9a8f972 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/FollowSetCard.kt @@ -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, + val users: ImmutableList, ) @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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt new file mode 100644 index 000000000..93a527567 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/FollowPackFeedScreen.kt @@ -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(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(baseNote, accountViewModel) + + Text( + text = noteEvent?.title() ?: baseNote.dTag(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt new file mode 100644 index 000000000..8d5e34deb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedFilter.kt @@ -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() { + 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 { + 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): Set = 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): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt new file mode 100644 index 000000000..3851b9dfb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedConversationsFeedViewModel.kt @@ -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 create(modelClass: Class): T = FollowPackFeedConversationsFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt new file mode 100644 index 000000000..ac8b756a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt new file mode 100644 index 000000000..74b9159a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedViewModel.kt @@ -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 create(modelClass: Class): T = FollowPackFeedNewThreadFeedViewModel(note, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt new file mode 100644 index 000000000..2329eca2c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersFeedFilter.kt @@ -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() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followPackNote.idHex + + val cache: MutableMap> = mutableMapOf() + + override fun feed(): List { + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt new file mode 100644 index 000000000..2eff19754 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackMembersUserFeedViewModel.kt @@ -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 create(modelClass: Class): T = FollowPackMembersUserFeedViewModel(followPackNote, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt new file mode 100644 index 000000000..ef3f1a91a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssembler.kt @@ -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() { + val group = + listOf( + FollowPackFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt new file mode 100644 index 000000000..c30204f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt new file mode 100644 index 000000000..80681a5f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/datasource/FollowPackFeedFilterSubAssembler.kt @@ -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, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 0b6c36a20..c322daa36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 217d898ef..4ffc25515 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -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 = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 758fee0d2..54f93434c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1350,4 +1350,7 @@ Public Members (%1$s) Add user to the list Add user to the list + + Follow Pack + Members