From a8936c54d818edcefbfbfd76a4f13b88578c1a1c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 6 Dec 2023 13:40:54 -0500 Subject: [PATCH] Adds a Marketplace tab to Discovery --- .../service/NostrDiscoveryDataSource.kt | 74 ++++++-- .../ui/dal/DiscoverMarketplaceFeedFilter.kt | 72 ++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/note/ChannelCardCompose.kt | 165 +++++++++++++++++- .../amethyst/ui/screen/FeedView.kt | 42 ++++- .../amethyst/ui/screen/FeedViewModel.kt | 11 ++ .../ui/screen/RememberForeverStates.kt | 27 +++ .../ui/screen/loggedIn/DiscoverScreen.kt | 78 ++++++--- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 7 + app/src/main/res/values/strings.xml | 1 + .../com/vitorpamplona/quartz/events/Event.kt | 4 +- .../quartz/events/EventInterface.kt | 1 + 12 files changed, 445 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index fd2463f77..a88dcc92b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent @@ -42,6 +43,54 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { job?.cancel() } + fun createMarketplaceFilter(): List { + val follows = account.liveDiscoveryFollowLists.value?.users?.toList() + val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList() + val geohashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList() + + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = follows, + kinds = listOf(ClassifiedsEvent.kind), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ), + hashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ClassifiedsEvent.kind), + tags = mapOf( + "t" to it.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ) + }, + geohashToLoad?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + kinds = listOf(ClassifiedsEvent.kind), + tags = mapOf( + "g" to it.map { + listOf(it, it.lowercase(), it.uppercase(), it.capitalize()) + }.flatten() + ), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList + ) + ) + } + ) + } + fun createLiveStreamFilter(): List { val follows = account.liveDiscoveryFollowLists.value?.users?.toList() @@ -238,16 +287,19 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { } override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus( - listOfNotNull( - createLiveStreamTagsFilter(), - createLiveStreamGeohashesFilter(), - createCommunitiesFilter(), - createPublicChatsTagsFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsGeohashesFilter() - ) - ).ifEmpty { null } + discoveryFeedChannel.typedFilters = createLiveStreamFilter() + .plus(createPublicChatFilter()) + .plus(createMarketplaceFilter()) + .plus( + listOfNotNull( + createLiveStreamTagsFilter(), + createLiveStreamGeohashesFilter(), + createCommunitiesFilter(), + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsTagsFilter(), + createPublicChatsGeohashesFilter() + ) + ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt new file mode 100644 index 000000000..80a14c1f3 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DiscoverMarketplaceFeedFilter.kt @@ -0,0 +1,72 @@ +package com.vitorpamplona.amethyst.ui.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.ClassifiedsEvent +import com.vitorpamplona.quartz.events.MuteListEvent +import com.vitorpamplona.quartz.events.PeopleListEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +open class DiscoverMarketplaceFeedFilter( + val account: Account +) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + "-" + followList() + } + + open fun followList(): String { + return account.defaultDiscoveryFollowList.value + } + + override fun showHiddenKey(): Boolean { + return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + } + + override fun feed(): List { + val classifieds = LocalCache.addressables + .filter { it.value.event is ClassifiedsEvent } + .map { it.value } + + val notes = innerApplyFilter(classifieds) + + return sort(notes) + } + + override fun applyFilter(collection: Set): Set { + return innerApplyFilter(collection) + } + + protected open fun innerApplyFilter(collection: Collection): Set { + val now = TimeUtils.now() + val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS + val isHiddenList = showHiddenKey() + + val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet() + val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet() + val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet() + + val activities = collection + .asSequence() + .filter { + it.event is ClassifiedsEvent && + it.event?.hasTagWithContent("image") == true && + it.event?.hasTagWithContent("price") == true && + it.event?.hasTagWithContent("title") == true + } + .filter { + isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true || it.event?.isTaggedGeoHashes(followingGeohashSet) == true + } + .filter { isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true } + .filter { (it.createdAt() ?: 0) <= now } + .toSet() + + return activities + } + + override fun sort(collection: Set): List { + return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 380d5f2d6..0dbe56c64 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -61,6 +62,7 @@ fun AppNavigation( knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, videoFeedViewModel: NostrVideoFeedViewModel, + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -137,6 +139,7 @@ fun AppNavigation( Route.Discover.let { route -> composable(route.route, route.arguments, content = { DiscoverScreen( + discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 2bd95649a..7b8559241 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -39,6 +42,7 @@ import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.distinctUntilChanged @@ -58,23 +62,28 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED import com.vitorpamplona.quartz.events.Participant +import com.vitorpamplona.quartz.events.Price import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -95,7 +104,7 @@ fun ChannelCardCompose( ) { val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null) - Crossfade(targetState = hasEvent) { + Crossfade(targetState = hasEvent, label = "ChannelCardCompose") { if (it) { if (forceEventKind == null || baseNote.event?.kind() == forceEventKind) { CheckHiddenChannelCardCompose( @@ -155,7 +164,7 @@ fun CheckHiddenChannelCardCompose( note.isHiddenFor(it) }.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note)) - Crossfade(targetState = isHidden) { + Crossfade(targetState = isHidden, label = "CheckHiddenChannelCardCompose") { if (!it) { LoadedChannelCardCompose( note, @@ -195,7 +204,7 @@ fun LoadedChannelCardCompose( } } - Crossfade(targetState = state) { + Crossfade(targetState = state, label = "CheckHiddenChannelCardCompose") { RenderChannelCardReportState( it, note, @@ -220,7 +229,7 @@ fun RenderChannelCardReportState( ) { var showReportedNote by remember { mutableStateOf(false) } - Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote -> + Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "CheckHiddenChannelCardCompose") { showHiddenNote -> if (showHiddenNote) { HiddenNote( state.relevantReports, @@ -334,6 +343,28 @@ fun InnerChannelCardWithReactions( baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit +) { + when (remember { baseNote.event }) { + is LiveActivitiesEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is CommunityDefinitionEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ChannelCreateEvent -> { + InnerCardRow(baseNote, accountViewModel, nav) + } + is ClassifiedsEvent -> { + InnerCardBox(baseNote, accountViewModel, nav) + } + } +} + +@Composable +fun InnerCardRow( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit ) { Column(StdPadding) { SensitivityWarning( @@ -353,6 +384,22 @@ fun InnerChannelCardWithReactions( ) } +@Composable +fun InnerCardBox( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + Column(HalfPadding) { + SensitivityWarning( + note = baseNote, + accountViewModel = accountViewModel + ) { + RenderClassifiedsThumb(baseNote, accountViewModel, nav) + } + } +} + @Composable private fun RenderNoteRow( baseNote: Note, @@ -372,6 +419,116 @@ private fun RenderNoteRow( } } +@Immutable +data class ClassifiedsThumb( + val image: String?, + val title: String?, + val price: Price? +) + +@Composable +fun RenderClassifiedsThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val noteEvent = baseNote.event as? ClassifiedsEvent ?: return + + val card by baseNote.live().metadata.map { + val noteEvent = it.note.event as? ClassifiedsEvent + + ClassifiedsThumb( + image = noteEvent?.image(), + title = noteEvent?.title(), + price = noteEvent?.price() + ) + }.distinctUntilChanged().observeAsState( + ClassifiedsThumb( + image = noteEvent.image(), + title = noteEvent.title(), + price = noteEvent.price() + ) + ) + + RenderClassifiedsThumb(card, baseNote.author) +} + +@Preview +@Composable +fun RenderClassifiedsThumbPreview() { + Surface(Modifier.size(200.dp)) { + RenderClassifiedsThumb( + card = ClassifiedsThumb( + image = null, + title = "Like New", + price = Price("800000", "SATS", null) + ), + author = null + ) + } +} + +@Composable +fun RenderClassifiedsThumb(card: ClassifiedsThumb, author: User?) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentAlignment = BottomStart + ) { + card.image?.let { + AsyncImage( + model = it, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } ?: run { + author?.let { + DisplayAuthorBanner(it) + } + } + + Row( + Modifier + .fillMaxWidth() + .background(Color.Black.copy(0.6f)) + .padding(Size5dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + card.title?.let { + Text( + text = it, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + modifier = Modifier.weight(1f) + ) + } + + card.price?.let { + val priceTag = remember(card) { + val newAmount = it.amount.toBigDecimalOrNull()?.let { + showAmountAxis(it) + } ?: it.amount + + if (it.frequency != null && it.currency != null) { + "$newAmount ${it.currency}/${it.frequency}" + } else if (it.currency != null) { + "$newAmount ${it.currency}" + } else { + newAmount + } + } + + Text( + text = priceTag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White + ) + } + } + } +} + @Immutable data class LiveActivityCard( val name: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 3df1af741..dcf94aca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button @@ -64,7 +66,9 @@ fun RefresheableView( val modifier = remember { if (enablePullRefresh) { - Modifier.fillMaxSize().pullRefresh(pullRefreshState) + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) } else { Modifier.fillMaxSize() } @@ -115,6 +119,23 @@ fun SaveableFeedState( content(listState) } +@Composable +fun SaveableGridFeedState( + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyGridState) -> Unit +) { + val gridState = if (scrollStateKey != null) { + rememberForeverLazyGridState(scrollStateKey) + } else { + rememberLazyGridState() + } + + WatchScrollToTop(viewModel, gridState) + + content(gridState) +} + @Composable private fun RenderFeed( viewModel: FeedViewModel, @@ -174,6 +195,21 @@ private fun WatchScrollToTop( } } +@Composable +private fun WatchScrollToTop( + viewModel: FeedViewModel, + listState: LazyGridState +) { + val scrollToTop by viewModel.scrollToTop.collectAsStateWithLifecycle() + + LaunchedEffect(scrollToTop) { + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() + } + } +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable private fun FeedLoaded( @@ -190,7 +226,9 @@ private fun FeedLoaded( itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> val (value, elapsed) = measureTimedValue { val defaultModifier = remember { - Modifier.fillMaxWidth().animateItemPlacement() + Modifier + .fillMaxWidth() + .animateItemPlacement() } Row(defaultModifier) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index e46d3211a..6360e4dae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.ui.dal.CommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter @@ -72,6 +73,16 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil } } +class NostrDiscoverMarketplaceFeedViewModel(val account: Account) : FeedViewModel( + DiscoverMarketplaceFeedFilter(account) +) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDiscoverMarketplaceFeedViewModel { + return NostrDiscoverMarketplaceFeedViewModel(account) as NostrDiscoverMarketplaceFeedViewModel + } + } +} + class NostrDiscoverLiveFeedViewModel(val account: Account) : FeedViewModel(DiscoverLiveFeedFilter(account)) { class Factory(val account: Account) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrDiscoverLiveFeedViewModel { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt index 9f79daf5a..46b3b4983 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable @@ -22,6 +23,7 @@ object ScrollStateKeys { val HOME_FOLLOWS = Route.Home.base + "Follows" val HOME_REPLIES = Route.Home.base + "FollowsReplies" + val DISCOVER_MARKETPLACE = Route.Home.base + "Marketplace" val DISCOVER_LIVE = Route.Home.base + "Live" val DISCOVER_COMMUNITY = Route.Home.base + "Communities" val DISCOVER_CHATS = Route.Home.base + "Chats" @@ -32,6 +34,31 @@ object PagerStateKeys { const val DISCOVER_SCREEN = "PagerDiscover" } +@Composable +fun rememberForeverLazyGridState( + key: String, + initialFirstVisibleItemIndex: Int = 0, + initialFirstVisibleItemScrollOffset: Int = 0 +): LazyGridState { + val scrollState = rememberSaveable(saver = LazyGridState.Saver) { + val savedValue = savedScrollStates[key] + val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex + val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat() + LazyGridState( + savedIndex, + savedOffset.roundToInt() + ) + } + DisposableEffect(scrollState) { + onDispose { + val lastIndex = scrollState.firstVisibleItemIndex + val lastOffset = scrollState.firstVisibleItemScrollOffset + savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat()) + } + } + return scrollState +} + @Composable fun rememberForeverLazyListState( key: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt index 596871b5f..e137e82cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DiscoverScreen.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -18,8 +19,8 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -47,14 +48,17 @@ import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys import com.vitorpamplona.amethyst.ui.screen.RefresheableView import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState +import com.vitorpamplona.amethyst.ui.screen.SaveableGridFeedState import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.events.ChannelCreateEvent +import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent import kotlinx.collections.immutable.ImmutableList @@ -64,6 +68,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun DiscoverScreen( + discoveryMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -75,6 +80,7 @@ fun DiscoverScreen( val tabs by remember(discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel, discoveryChatFeedViewModel) { mutableStateOf( listOf( + TabItem(R.string.discover_marketplace, discoveryMarketplaceFeedViewModel, Route.Discover.base + "Marketplace", ScrollStateKeys.DISCOVER_MARKETPLACE, ClassifiedsEvent.kind), TabItem(R.string.discover_live, discoveryLiveFeedViewModel, Route.Discover.base + "Live", ScrollStateKeys.DISCOVER_LIVE, LiveActivitiesEvent.kind), TabItem(R.string.discover_community, discoveryCommunityFeedViewModel, Route.Discover.base + "Community", ScrollStateKeys.DISCOVER_COMMUNITY, CommunityDefinitionEvent.kind), TabItem(R.string.discover_chat, discoveryChatFeedViewModel, Route.Discover.base + "Chats", ScrollStateKeys.DISCOVER_CHATS, ChannelCreateEvent.kind) @@ -85,6 +91,7 @@ fun DiscoverScreen( val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel = discoveryMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, @@ -122,11 +129,12 @@ private fun DiscoverPages( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - TabRow( + ScrollableTabRow( containerColor = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onBackground, selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight + modifier = TabRowHeight, + edgePadding = 8.dp ) { val coroutineScope = rememberCoroutineScope() @@ -145,15 +153,28 @@ private fun DiscoverPages( HorizontalPager(state = pagerState) { page -> RefresheableView(tabs[page].viewModel, true) { - SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - viewModel = tabs[page].viewModel, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav - ) + if (tabs[page].viewModel is NostrDiscoverMarketplaceFeedViewModel) { + SaveableGridFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav + ) + } + } else { + SaveableFeedState(tabs[page].viewModel, scrollStateKey = tabs[page].scrollStateKey) { listState -> + RenderDiscoverFeed( + viewModel = tabs[page].viewModel, + routeForLastRead = tabs[page].routeForLastRead, + forceEventKind = tabs[page].forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav + ) + } } } } @@ -164,7 +185,7 @@ private fun RenderDiscoverFeed( viewModel: FeedViewModel, routeForLastRead: String?, forceEventKind: Int?, - listState: LazyListState, + listState: ScrollableState, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { @@ -189,14 +210,25 @@ private fun RenderDiscoverFeed( } is FeedState.Loaded -> { - DiscoverFeedLoaded( - state, - routeForLastRead, - listState, - forceEventKind, - accountViewModel, - nav - ) + if (listState is LazyGridState) { + DiscoverFeedColumnsLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav + ) + } else if (listState is LazyListState) { + DiscoverFeedLoaded( + state, + routeForLastRead, + listState, + forceEventKind, + accountViewModel, + nav + ) + } } is FeedState.Loading -> { @@ -208,6 +240,7 @@ private fun RenderDiscoverFeed( @Composable fun WatchAccountForDiscoveryScreen( + discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel, discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel, discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel, @@ -217,6 +250,7 @@ fun WatchAccountForDiscoveryScreen( LaunchedEffect(accountViewModel, listState) { NostrDiscoveryDataSource.resetFilters() + discoverMarketplaceFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryLiveFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryCommunityFeedViewModel.checkKeysInvalidateDataAndSendToTop() discoveryChatFeedViewModel.checkKeysInvalidateDataAndSendToTop() @@ -260,7 +294,7 @@ private fun DiscoverFeedLoaded( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun DiscoverFeedTwoColumnsLoaded( +private fun DiscoverFeedColumnsLoaded( state: FeedState.Loaded, routeForLastRead: String?, listState: LazyGridState, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 14810b779..0419964f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel @@ -169,6 +170,11 @@ fun MainScreen( factory = NostrVideoFeedViewModel.Factory(accountViewModel.account) ) + val discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel = viewModel( + key = "NostrDiscoveryMarketplaceFeedViewModel", + factory = NostrDiscoverMarketplaceFeedViewModel.Factory(accountViewModel.account) + ) + val discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel = viewModel( key = "NostrDiscoveryLiveFeedViewModel", factory = NostrDiscoverLiveFeedViewModel.Factory(accountViewModel.account) @@ -364,6 +370,7 @@ fun MainScreen( knownFeedViewModel = knownFeedViewModel, newFeedViewModel = newFeedViewModel, videoFeedViewModel = videoFeedViewModel, + discoverMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel, discoveryLiveFeedViewModel = discoveryLiveFeedViewModel, discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel, discoveryChatFeedViewModel = discoveryChatFeedViewModel, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5bad273bc..64310b2e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -489,6 +489,7 @@ Relays + Marketplace Live Community Chats diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index b93eddbb9..62494293d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -64,7 +64,9 @@ open class Event( override fun toJson(): String = mapper.writeValueAsString(toJsonObject()) - override fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" } + override fun hasAnyTaggedUser() = hasTagWithContent("p") + + override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index ed4cbd8f9..0d2747f25 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -68,6 +68,7 @@ interface EventInterface { fun zapraiserAmount(): Long? fun hasAnyTaggedUser(): Boolean + fun hasTagWithContent(tagName: String): Boolean fun taggedAddresses(): List fun taggedUsers(): List