Adds a Reads feed to the discovery tab

This commit is contained in:
Vitor Pamplona
2025-05-06 15:48:03 -04:00
parent 80d4971548
commit fcd70001bf
12 changed files with 202 additions and 30 deletions
@@ -63,12 +63,15 @@ import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
@@ -94,12 +97,20 @@ class AddressableNote(
override fun address() = address
override fun createdAt(): Long? {
if (event == null) return null
val currentEvent = event
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt ?: Long.MAX_VALUE
if (currentEvent == null) return null
return minOf(publishedAt, lastCreatedAt)
val publishedAt =
when (currentEvent) {
is LongTextNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is WikiNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is VideoEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
is ClassifiedsEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE
else -> Long.MAX_VALUE
}
return minOf(publishedAt, currentEvent.createdAt)
}
fun dTag(): String = address.dTag
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card
val DefaultFeedOrder: Comparator<Note> =
compareByDescending<Note> { it.event?.createdAt }.thenBy { it.idHex }
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
@@ -46,6 +46,7 @@ object ScrollStateKeys {
const val DRAFTS = "DraftsFeed"
const val DISCOVER_FOLLOWS = "DiscoverFollowSetsFeed"
const val DISCOVER_READS = "DiscoverReadsFeed"
const val DISCOVER_CONTENT = "DiscoverDiscoverContentFeed"
const val DISCOVER_MARKETPLACE = "DiscoverMarketplaceFeed"
const val DISCOVER_LIVE = "DiscoverLiveFeed"
@@ -60,7 +60,7 @@ fun RenderLongFormContent(
}
@Composable
private fun LongFormHeader(
fun LongFormHeader(
noteEvent: LongTextNoteEvent,
note: Note,
accountViewModel: AccountViewModel,
@@ -83,10 +83,7 @@ private fun LongFormHeader(
QuoteBorder,
),
) {
val automaticallyShowUrlPreview =
remember { accountViewModel.settings.showImages.value }
if (automaticallyShowUrlPreview) {
if (accountViewModel.settings.showImages.value) {
image?.let {
AsyncImage(
model = it,
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.screen.FollowListState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.DiscoverChatFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.DiscoverFollowSetsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.DiscoverLiveFeedFilter
@@ -55,6 +56,7 @@ class AccountFeedContentStates(
val videoFeed = FeedContentState(VideoFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val discoverFollowSets = FeedContentState(DiscoverFollowSetsFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val discoverReads = FeedContentState(DiscoverLongFormFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val discoverMarketplace = FeedContentState(DiscoverMarketplaceFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val discoverDVMs = FeedContentState(DiscoverNIP89FeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
val discoverLive = FeedContentState(DiscoverLiveFeedFilter(accountViewModel.account), accountViewModel.viewModelScope)
@@ -84,6 +86,8 @@ class AccountFeedContentStates(
videoFeed.updateFeedWith(newNotes)
discoverMarketplace.updateFeedWith(newNotes)
discoverFollowSets.updateFeedWith(newNotes)
discoverReads.updateFeedWith(newNotes)
discoverDVMs.updateFeedWith(newNotes)
discoverLive.updateFeedWith(newNotes)
discoverCommunities.updateFeedWith(newNotes)
@@ -105,6 +109,8 @@ class AccountFeedContentStates(
videoFeed.destroy()
discoverMarketplace.destroy()
discoverFollowSets.destroy()
discoverReads.destroy()
discoverDVMs.destroy()
discoverLive.destroy()
discoverCommunities.destroy()
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.RenderLongFormThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats.RenderChannelThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets.RenderFollowSetThumb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.RenderLiveActivityThumb
@@ -44,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.RenderCo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.RenderClassifiedsThumb
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -154,6 +156,7 @@ fun InnerChannelCardWithReactions(
is ClassifiedsEvent -> InnerCardBox(baseNote, accountViewModel, nav)
is AppDefinitionEvent -> InnerCardRow(baseNote, accountViewModel, nav)
is FollowListEvent -> InnerCardRow(baseNote, accountViewModel, nav)
is LongTextNoteEvent -> InnerCardRow(baseNote, accountViewModel, nav)
}
}
@@ -200,20 +203,11 @@ private fun RenderNoteRow(
nav: INav,
) {
when (baseNote.event) {
is LiveActivitiesEvent -> {
RenderLiveActivityThumb(baseNote, accountViewModel, nav)
}
is CommunityDefinitionEvent -> {
RenderCommunitiesThumb(baseNote, accountViewModel, nav)
}
is ChannelCreateEvent -> {
RenderChannelThumb(baseNote, accountViewModel, nav)
}
is AppDefinitionEvent -> {
RenderContentDVMThumb(baseNote, accountViewModel, nav)
}
is FollowListEvent -> {
RenderFollowSetThumb(baseNote, accountViewModel, nav)
}
is LiveActivitiesEvent -> RenderLiveActivityThumb(baseNote, accountViewModel, nav)
is CommunityDefinitionEvent -> RenderCommunitiesThumb(baseNote, accountViewModel, nav)
is ChannelCreateEvent -> RenderChannelThumb(baseNote, accountViewModel, nav)
is AppDefinitionEvent -> RenderContentDVMThumb(baseNote, accountViewModel, nav)
is FollowListEvent -> RenderFollowSetThumb(baseNote, accountViewModel, nav)
is LongTextNoteEvent -> RenderLongFormThumb(baseNote, accountViewModel, nav)
}
}
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -98,6 +99,7 @@ fun DiscoverScreen(
) {
DiscoverScreen(
discoveryFollowSetsFeedContentState = accountViewModel.feedStates.discoverFollowSets,
discoveryReadsFeedContentState = accountViewModel.feedStates.discoverReads,
discoveryContentNIP89FeedContentState = accountViewModel.feedStates.discoverDVMs,
discoveryMarketplaceFeedContentState = accountViewModel.feedStates.discoverMarketplace,
discoveryLiveFeedContentState = accountViewModel.feedStates.discoverLive,
@@ -112,6 +114,7 @@ fun DiscoverScreen(
@Composable
fun DiscoverScreen(
discoveryFollowSetsFeedContentState: FeedContentState,
discoveryReadsFeedContentState: FeedContentState,
discoveryContentNIP89FeedContentState: FeedContentState,
discoveryMarketplaceFeedContentState: FeedContentState,
discoveryLiveFeedContentState: FeedContentState,
@@ -128,9 +131,16 @@ fun DiscoverScreen(
R.string.discover_follows,
discoveryFollowSetsFeedContentState,
"DiscoverFollowSets",
ScrollStateKeys.DISCOVER_CONTENT,
ScrollStateKeys.DISCOVER_FOLLOWS,
FollowListEvent.KIND,
),
TabItem(
R.string.discover_reads,
discoveryReadsFeedContentState,
"DiscoverReads",
ScrollStateKeys.DISCOVER_READS,
LongTextNoteEvent.KIND,
),
TabItem(
R.string.discover_content,
discoveryContentNIP89FeedContentState,
@@ -175,6 +185,7 @@ fun DiscoverScreen(
WatchAccountForDiscoveryScreen(
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
discoveryReadsFeedContentState = discoveryReadsFeedContentState,
discoveryContentNIP89FeedContentState = discoveryContentNIP89FeedContentState,
discoveryMarketplaceFeedContentState = discoveryMarketplaceFeedContentState,
discoveryLiveFeedContentState = discoveryLiveFeedContentState,
@@ -184,6 +195,7 @@ fun DiscoverScreen(
)
WatchLifecycleAndUpdateModel(discoveryFollowSetsFeedContentState)
WatchLifecycleAndUpdateModel(discoveryReadsFeedContentState)
WatchLifecycleAndUpdateModel(discoveryContentNIP89FeedContentState)
WatchLifecycleAndUpdateModel(discoveryMarketplaceFeedContentState)
WatchLifecycleAndUpdateModel(discoveryLiveFeedContentState)
@@ -379,6 +391,7 @@ private fun RenderDiscoverFeed(
@Composable
fun WatchAccountForDiscoveryScreen(
discoveryFollowSetsFeedContentState: FeedContentState,
discoveryReadsFeedContentState: FeedContentState,
discoveryContentNIP89FeedContentState: FeedContentState,
discoveryMarketplaceFeedContentState: FeedContentState,
discoveryLiveFeedContentState: FeedContentState,
@@ -390,6 +403,7 @@ fun WatchAccountForDiscoveryScreen(
LaunchedEffect(accountViewModel, listState) {
discoveryFollowSetsFeedContentState.checkKeysInvalidateDataAndSendToTop()
discoveryReadsFeedContentState.checkKeysInvalidateDataAndSendToTop()
discoveryContentNIP89FeedContentState.checkKeysInvalidateDataAndSendToTop()
discoveryMarketplaceFeedContentState.checkKeysInvalidateDataAndSendToTop()
discoveryLiveFeedContentState.checkKeysInvalidateDataAndSendToTop()
@@ -30,6 +30,7 @@ import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.datasources.Subscription
import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -251,7 +252,26 @@ class DiscoveryFilterAssembler(
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(FollowListEvent.KIND),
limit = 500,
limit = 300,
since = since(key),
),
),
)
}
fun createLongFormFilter(key: DiscoveryQueryState): List<TypedFilter> {
val follows =
key.account.liveDiscoveryListAuthorsPerRelay.value
?.ifEmpty { null }
return listOfNotNull(
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
SinceAuthorPerRelayFilter(
authors = follows,
kinds = listOf(LongTextNoteEvent.KIND),
limit = 300,
since = since(key),
),
),
@@ -471,6 +491,7 @@ class DiscoveryFilterAssembler(
.plus(createNIP89Filter(key))
.plus(createPublicChatFilter(key))
.plus(createFollowSetFilter(key))
.plus(createLongFormFilter(key))
.plus(createMarketplaceFilter(key))
.plus(
listOfNotNull(
@@ -0,0 +1,76 @@
/**
* Copyright (c) 2024 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.discover.nip23LongForm
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
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.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip51Lists.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
open class DiscoverLongFormFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
override fun showHiddenKey(): Boolean =
followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) ||
followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex)
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet { _, it ->
val noteEvent = it.event
noteEvent is LongTextNoteEvent && params.match(noteEvent)
}
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> = innerApplyFilter(collection)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.Companion.create(
account.userProfile().pubkeyHex,
account.settings.defaultDiscoveryFollowList.value,
account.liveDiscoveryFollowLists.value,
account.flowHiddenUsers.value,
)
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is LongTextNoteEvent && params.match(noteEvent)
}
}
override fun sort(collection: Set<Note>): List<Note> = collection.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2024 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.discover.nip23LongForm
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.types.LongFormHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@Composable
fun RenderLongFormThumb(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent by observeNoteEvent<LongTextNoteEvent>(baseNote, accountViewModel)
noteEvent?.let {
LongFormHeader(
it,
baseNote,
accountViewModel,
)
}
}
@@ -162,7 +162,10 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
@@ -933,7 +936,7 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
it,
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
modifier = MaterialTheme.colorScheme.imageModifier,
)
}
@@ -951,11 +954,12 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
.summary()
?.ifBlank { null }
?.let {
Spacer(modifier = DoubleVertSpacer)
Spacer(modifier = StdVertSpacer)
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
color = Color.Gray,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
}
}
+1
View File
@@ -649,6 +649,7 @@
<string name="relay_setup">Relays</string>
<string name="discover_follows">Follow Packs</string>
<string name="discover_reads">Reads</string>
<string name="discover_content">Feed Algorithms</string>
<string name="discover_marketplace">Marketplace</string>
<string name="discover_live">Live Streams</string>