From 9fb8d4821e3609c322f9f30335b0c110b1cc2fae Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 17 May 2024 17:02:04 -0400 Subject: [PATCH] Refactoring the DVM codebase Allows pull to refresh to request the job again. --- .../vitorpamplona/amethyst/model/Account.kt | 12 + .../service/NostrDiscoveryDataSource.kt | 40 --- .../service/NostrSingleEventDataSource.kt | 4 + .../ui/dal/NIP90ContentDiscoveryFilter.kt | 132 --------- ...=> NIP90ContentDiscoveryResponseFilter.kt} | 72 +++-- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/screen/FeedView.kt | 31 +-- .../amethyst/ui/screen/FeedViewModel.kt | 15 +- .../ui/screen/loggedIn/AccountViewModel.kt | 32 +++ .../loggedIn/NIP90ContentDiscoveryScreen.kt | 250 +++++++++++------- app/src/main/res/values/strings.xml | 4 +- .../amethyst/commons/data/LargeCache.kt | 26 ++ .../com/vitorpamplona/quartz/events/Event.kt | 7 + .../quartz/events/EventInterface.kt | 2 + .../NIP90ContentDiscoveryRequestEvent.kt | 4 +- .../NIP90ContentDiscoveryResponseEvent.kt | 29 ++ 16 files changed, 335 insertions(+), 329 deletions(-) delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt rename app/src/main/java/com/vitorpamplona/amethyst/ui/dal/{NIP90StatusFilter.kt => NIP90ContentDiscoveryResponseFilter.kt} (62%) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9e50eeb8f..f4ec0dc85 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.NIP17Factory +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PollNoteEvent @@ -2275,6 +2276,17 @@ class Account( } } + fun requestDVMContentDiscovery( + dvmPublicKey: String, + onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, + ) { + NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer) { + Client.send(it) + LocalCache.justConsume(it, null) + onReady(it) + } + } + fun unwrap( event: GiftWrapEvent, onReady: (Event) -> Unit, 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 ab0d301a8..160daee76 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -35,8 +35,6 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesEvent -import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.events.NIP90StatusEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -153,42 +151,6 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { ) } - fun createNIP90ResponseFilter(): List { - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - ) - } - - fun createNIP90StatusFilter(): List { - return listOfNotNull( - TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = - JsonFilter( - kinds = listOf(NIP90StatusEvent.KIND), - limit = 300, - since = - latestEOSEs.users[account.userProfile()] - ?.followList - ?.get(account.defaultDiscoveryFollowList.value) - ?.relayList, - ), - ), - ) - } - fun createLiveStreamFilter(): List { val follows = account.liveDiscoveryFollowLists.value?.users?.toList() @@ -463,8 +425,6 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { discoveryFeedChannel.typedFilters = createLiveStreamFilter() .plus(createNIP89Filter(listOf("5300"))) - .plus(createNIP90ResponseFilter()) - .plus(createNIP90StatusFilter()) .plus(createPublicChatFilter()) .plus(createMarketplaceFilter()) .plus( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index dd6ce9b60..d8cb82db2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.ReactionEvent @@ -171,6 +173,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { kinds = listOf( DeletionEvent.KIND, + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, ), tags = mapOf("e" to it.map { it.idHex }), since = findMinimumEOSEs(it), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt deleted file mode 100644 index 0fc4cc2a9..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryFilter.kt +++ /dev/null @@ -1,132 +0,0 @@ -/** - * 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.dal - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.quartz.events.MuteListEvent -import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent -import com.vitorpamplona.quartz.events.PeopleListEvent - -open class NIP90ContentDiscoveryFilter( - val account: Account, - val dvmkey: String, - val request: String, -) : AdditiveFeedFilter() { - override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + request - } - - 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 params = buildFilterParams(account) - - val notes = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is NIP90ContentDiscoveryResponseEvent && it.event!!.isTaggedEvent(request) - // it.event?.pubKey() == dvmkey && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) - } - - var sorted = sort(notes) - if (sorted.isNotEmpty()) { - var note = sorted.first() - - var eventContent = note.event?.content() - - var collection: MutableSet = mutableSetOf() - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - var etags = mapper.readValue(eventContent, List::class.java) - for (element in etags) { - var tag = mapper.readValue(mapper.writeValueAsString(element), Array::class.java) - val note = LocalCache.checkGetOrCreateNote(tag[1].toString()) - if (note != null) { - collection.add(note) - } - } - - return collection.toList() - } else { - return listOf() - } - } - - override fun applyFilter(collection: Set): Set { - return innerApplyFilter(collection) - } - - fun buildFilterParams(account: Account): FilterByListParams { - return FilterByListParams.create( - account.userProfile().pubkeyHex, - account.defaultDiscoveryFollowList.value, - account.liveDiscoveryFollowLists.value, - account.flowHiddenUsers.value, - ) - } - - protected open fun innerApplyFilter(collection: Collection): Set { - // val params = buildFilterParams(account) - - val notes = - collection.filterTo(HashSet()) { - val noteEvent = it.event - noteEvent is NIP90ContentDiscoveryResponseEvent && // && - it.event!!.isTaggedEvent(request) // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) - } - - val sorted = sort(notes) - if (sorted.isNotEmpty()) { - var note = sorted.first() - - var eventContent = note.event?.content() - // println(eventContent) - - val collection: MutableSet = mutableSetOf() - val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - var etags = mapper.readValue(eventContent, Array::class.java) - for (element in etags) { - var tag = mapper.readValue(mapper.writeValueAsString(element), Array::class.java) - val note = LocalCache.checkGetOrCreateNote(tag[1].toString()) - if (note != null) { - collection.add(note) - } - } - return collection - } else { - return hashSetOf() - } - } - - override fun sort(collection: Set): List { - return collection.toList() // collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt similarity index 62% rename from app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt index 6cf787bfe..408803dc9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90StatusFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -24,16 +24,18 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.MuteListEvent -import com.vitorpamplona.quartz.events.NIP90StatusEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.PeopleListEvent -open class NIP90StatusFilter( +open class NIP90ContentDiscoveryResponseFilter( val account: Account, val dvmkey: String, val request: String, ) : AdditiveFeedFilter() { + var latestNote: Note? = null + override fun feedKey(): String { - return account.userProfile().pubkeyHex + "-" + followList() + return account.userProfile().pubkeyHex + "-" + request } open fun followList(): String { @@ -45,20 +47,41 @@ open class NIP90StatusFilter( followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) } + fun acceptableEvent(note: Note): Boolean { + val noteEvent = note.event + return noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.isTaggedEvent(request) + } + + val createAtComparator = { first: Note?, second: Note? -> + val firstEvent = first?.event + val secondEvent = second?.event + + if (firstEvent == null && secondEvent == null) { + 0 + } else if (firstEvent == null) { + 1 + } else if (secondEvent == null) { + -1 + } else { + firstEvent.createdAt().compareTo(secondEvent.createdAt()) + } + } + override fun feed(): List { val params = buildFilterParams(account) - val status = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && - it.event!!.isTaggedEvent(request) - // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) - } - if (status.isNotEmpty()) { - return listOf(status.first()) - } else { - return listOf() + latestNote = + LocalCache.notes.maxOrNullOf( + filter = { idHex: String, note: Note -> + acceptableEvent(note) + }, + comparator = createAtComparator, + ) + + val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return listOf() + + return noteEvent.innerTags().mapNotNull { + LocalCache.checkGetOrCreateNote(it) } } @@ -78,18 +101,17 @@ open class NIP90StatusFilter( protected open fun innerApplyFilter(collection: Collection): Set { // val params = buildFilterParams(account) - val status = - LocalCache.notes.filterIntoSet { _, it -> - val noteEvent = it.event - noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && - it.event!!.isTaggedEvent(request) - // && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) - } - if (status.isNotEmpty()) { - return setOf(status.first()) - } else { - return setOf() + val maxNote = collection.filter { acceptableEvent(it) }.maxByOrNull { it.createdAt() ?: 0 } ?: return emptySet() + + if ((maxNote.createdAt() ?: 0) > (latestNote?.createdAt() ?: 0)) { + latestNote = maxNote } + + val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return setOf() + + return noteEvent.innerTags().mapNotNull { + LocalCache.checkGetOrCreateNote(it) + }.toSet() } override fun sort(collection: Set): List { 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 51250e69d..1ac3961c0 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 @@ -227,9 +227,9 @@ fun AppNavigation( route.route, route.arguments, content = { - it.arguments?.getString("id")?.let { it1 -> + it.arguments?.getString("id")?.let { id -> NIP90ContentDiscoveryScreen( - DVMID = it1, + dvmPublicKey = id, accountViewModel = accountViewModel, nav = nav, ) 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 188fd589c..8ceb5205d 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 @@ -97,11 +97,24 @@ fun RefresheableBox( viewModel: InvalidatableViewModel, enablePullRefresh: Boolean = true, content: @Composable () -> Unit, +) { + RefresheableBox( + enablePullRefresh = enablePullRefresh, + onRefresh = { viewModel.invalidateData() }, + content = content, + ) +} + +@Composable +fun RefresheableBox( + enablePullRefresh: Boolean = true, + onRefresh: () -> Unit, + content: @Composable () -> Unit, ) { var refreshing by remember { mutableStateOf(false) } val refresh = { refreshing = true - viewModel.invalidateData() + onRefresh() refreshing = false } val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) @@ -301,19 +314,3 @@ fun FeedEmpty(onRefresh: () -> Unit) { OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } } } - -@Composable -fun FeedEmptywithStatus( - status: String, - onRefresh: () -> Unit, -) { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(status) - // Spacer(modifier = StdVertSpacer) - // OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } - } -} 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 891ec905a..7685b2746 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 @@ -54,8 +54,7 @@ import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryFilter -import com.vitorpamplona.amethyst.ui.dal.NIP90StatusFilter +import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter @@ -285,7 +284,7 @@ class NostrBookmarkPrivateFeedViewModel(val account: Account) : @Stable class NostrNIP90ContentDiscoveryFeedViewModel(val account: Account, val dvmkey: String, val requestid: String) : - FeedViewModel(NIP90ContentDiscoveryFilter(account, dvmkey, requestid)) { + FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmkey, requestid)) { class Factory(val account: Account, val dvmkey: String, val requestid: String) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrNIP90ContentDiscoveryFeedViewModel { return NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as NostrNIP90ContentDiscoveryFeedViewModel @@ -293,16 +292,6 @@ class NostrNIP90ContentDiscoveryFeedViewModel(val account: Account, val dvmkey: } } -@Stable -class NostrNIP90StatusFeedViewModel(val account: Account, val dvmkey: String, val requestid: String) : - FeedViewModel(NIP90StatusFilter(account, dvmkey, requestid)) { - class Factory(val account: Account, val dvmkey: String, val requestid: String) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrNIP90StatusFeedViewModel { - return NostrNIP90StatusFeedViewModel(account, dvmkey, requestid) as NostrNIP90StatusFeedViewModel - } - } -} - @Stable class NostrDraftEventsFeedViewModel(val account: Account) : FeedViewModel(DraftEventsFeedFilter(account)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 498e1969a..e672d7a1b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.HttpClientManager @@ -98,6 +99,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -174,6 +176,25 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View account.reactTo(note, reaction) } + fun observeByETag( + kind: Int, + eTag: HexKey, + ): StateFlow { + val observable = + LocalCache.observeETag( + kind = kind, + eventId = eTag, + ) { + LatestByKindWithETag(kind, eTag) + } + + viewModelScope.launch(Dispatchers.IO) { + observable.init() + } + + return observable.latest + } + fun reactToOrDelete( note: Note, reaction: String, @@ -1321,6 +1342,17 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View return note } + fun requestDVMContentDiscovery( + dvmPublicKey: String, + onReady: (event: Note) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + account.requestDVMContentDiscovery(dvmPublicKey) { + onReady(LocalCache.getOrCreateNote(it.id)) + } + } + } + val draftNoteCache = CachedDraftNotes(this) class CachedDraftNotes(val accountViewModel: AccountViewModel) : GenericBaseCacheAsync(20) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt index 5c0121309..84b98258a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NIP90ContentDiscoveryScreen.kt @@ -20,137 +20,195 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relays.Client -import com.vitorpamplona.amethyst.ui.screen.FeedEmptywithStatus +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.NostrNIP90ContentDiscoveryFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.NostrNIP90StatusFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableBox import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState -import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent +import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.events.NIP90StatusEvent @Composable fun NIP90ContentDiscoveryScreen( - DVMID: String, + dvmPublicKey: String, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - var requestID = "" - val thread = - Thread { - try { - NIP90ContentDiscoveryRequestEvent.create(DVMID, accountViewModel.account.signer) { - Client.send(it) - requestID = it.id - LocalCache.justConsume(it, null) - } - } catch (e: Exception) { - e.printStackTrace() - } + var requestEventID by + remember(dvmPublicKey) { + mutableStateOf(null) } - thread.start() - thread.join() - - val resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel = - viewModel( - key = "NostrNIP90ContentDiscoveryFeedViewModel", - factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = DVMID, requestid = requestID), - ) - - val statusFeedViewModel: NostrNIP90StatusFeedViewModel = - viewModel( - key = "NostrNIP90StatusFeedViewModel", - factory = NostrNIP90StatusFeedViewModel.Factory(accountViewModel.account, dvmkey = DVMID, requestid = requestID), - ) - - val userState by accountViewModel.account.decryptBookmarks.observeAsState() // TODO - - LaunchedEffect(userState) { - resultFeedViewModel.invalidateData() + val onRefresh = { + accountViewModel.requestDVMContentDiscovery(dvmPublicKey) { + requestEventID = it + } } - RenderNostrNIP90ContentDiscoveryScreen(DVMID, accountViewModel, nav, resultFeedViewModel, statusFeedViewModel) + LaunchedEffect(key1 = dvmPublicKey) { + onRefresh() + } + + RefresheableBox( + onRefresh = onRefresh, + ) { + val myRequestEventID = requestEventID + if (myRequestEventID != null) { + ObserverContentDiscoveryResponse( + dvmPublicKey, + myRequestEventID, + onRefresh, + accountViewModel, + nav, + ) + } else { + // TODO: Make a good splash screen with loading animation for this DVM. + FeedEmptywithStatus(stringResource(R.string.dvm_requesting_job)) + } + } } @Composable -@OptIn(ExperimentalFoundationApi::class) -fun RenderNostrNIP90ContentDiscoveryScreen( - dvmID: String?, +fun ObserverContentDiscoveryResponse( + dvmPublicKey: String, + dvmRequestId: Note, + onRefresh: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit, +) { + val updateFiltersFromRelays = dvmRequestId.live().metadata.observeAsState() + + val resultFlow = + remember(dvmRequestId) { + accountViewModel.observeByETag(NIP90ContentDiscoveryResponseEvent.KIND, dvmRequestId.idHex) + } + + val latestResponse by resultFlow.collectAsStateWithLifecycle() + + if (latestResponse != null) { + PrepareViewContentDiscoveryModels( + dvmPublicKey, + dvmRequestId.idHex, + onRefresh, + accountViewModel, + nav, + ) + } else { + ObserverDvmStatusResponse( + dvmPublicKey, + dvmRequestId.idHex, + accountViewModel, + nav, + ) + } +} + +@Composable +fun ObserverDvmStatusResponse( + dvmPublicKey: String, + dvmRequestId: String, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val statusFlow = + remember(dvmRequestId) { + accountViewModel.observeByETag(NIP90StatusEvent.KIND, dvmRequestId) + } + + val latestStatus by statusFlow.collectAsStateWithLifecycle() + + if (latestStatus != null) { + // TODO: Make a good splash screen with loading animation for this DVM. + latestStatus?.let { + FeedEmptywithStatus(it.content()) + } + } else { + // TODO: Make a good splash screen with loading animation for this DVM. + FeedEmptywithStatus(stringResource(R.string.dvm_waiting_status)) + } +} + +@Composable +fun PrepareViewContentDiscoveryModels( + dvmPublicKey: String, + dvmRequestId: String, + onRefresh: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel = + viewModel( + key = "NostrNIP90ContentDiscoveryFeedViewModel$dvmPublicKey$dvmRequestId", + factory = NostrNIP90ContentDiscoveryFeedViewModel.Factory(accountViewModel.account, dvmkey = dvmPublicKey, requestid = dvmRequestId), + ) + + LaunchedEffect(key1 = dvmRequestId) { + resultFeedViewModel.invalidateData() + } + + RenderNostrNIP90ContentDiscoveryScreen(resultFeedViewModel, onRefresh, accountViewModel, nav) +} + +@Composable +fun RenderNostrNIP90ContentDiscoveryScreen( resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel, - statusFeedViewModel: NostrNIP90StatusFeedViewModel, + onRefresh: () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { Column(Modifier.fillMaxHeight()) { - val pagerState = rememberPagerState { 2 } - val coroutineScope = rememberCoroutineScope() - - // TODO (Optional) this now shows the first status update but there might be a better way - var dvmState = stringResource(R.string.dvm_waiting_status) - var dvmNoState = stringResource(R.string.dvm_no_status) - - val thread = - Thread { - var count = 0 - while (resultFeedViewModel.localFilter.feed().isEmpty()) { - try { - if (statusFeedViewModel.localFilter.feed().isNotEmpty()) { - statusFeedViewModel.localFilter.feed()[0].event?.let { dvmState = it.content() } - println(dvmState) - break - } else if (count > 1000) { - dvmState = dvmNoState - // Might not be the best way, but we want to avoid hanging in the loop forever - } else { - count++ - } - } catch (e: Exception) { - e.printStackTrace() - } - } - } - thread.start() - thread.join() - // TODO (Optional) Maybe render a nice header with image and DVM name from the dvmID // TODO (Optional) How do we get the event information here?, LocalCache.checkGetOrCreateNote() returns note but event is empty // TODO (Optional) otherwise we have the NIP89 info in (note.event as AppDefinitionEvent).appMetaData() - // Text(text = dvminfo) - - HorizontalPager(state = pagerState) { - RefresheableBox(resultFeedViewModel, false) { - SaveableFeedState(resultFeedViewModel, null) { listState -> - // TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation - RenderFeedState( - resultFeedViewModel, - accountViewModel, - listState, - nav, - null, - onEmpty = { - // TODO (Optional) Maybe also show some dvm image/text while waiting for the notes in this custom component - FeedEmptywithStatus(status = dvmState) { - } - }, - ) - } - } + SaveableFeedState(resultFeedViewModel, null) { listState -> + // TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation + RenderFeedState( + resultFeedViewModel, + accountViewModel, + listState, + nav, + null, + onEmpty = { + // TODO (Optional) Maybe also show some dvm image/text while waiting for the notes in this custom component + FeedEmpty { + onRefresh() + } + }, + ) } } } + +@Composable +fun FeedEmptywithStatus(status: String) { + Column( + Modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(status) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cf9242a4..84cf4a04e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -847,6 +847,6 @@ From Msg - Waiting for DVM to reply - DVM seems not to reply + Job Requested, waiting for a reply + Requesting Job from DVM diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt index 73d958f99..9a7969004 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/data/LargeCache.kt @@ -103,6 +103,15 @@ class LargeCache { return runner.results } + fun maxOrNullOf( + filter: BiFilter, + comparator: Comparator, + ): V? { + val runner = BiMaxOfCollector(filter, comparator) + innerForEach(runner) + return runner.maxV + } + fun sumOf(consumer: BiSumOf): Int { val runner = BiSumOfCollector(consumer) innerForEach(runner) @@ -263,6 +272,23 @@ fun interface BiSumOf { ): Int } +class BiMaxOfCollector(val filter: BiFilter, val comparator: Comparator) : BiConsumer { + var maxK: K? = null + var maxV: V? = null + + override fun accept( + k: K, + v: V, + ) { + if (filter.filter(k, v)) { + if (maxK == null || comparator.compare(v, maxV) > 1) { + maxK = k + maxV = v + } + } + } +} + class BiSumOfCollector(val mapper: BiSumOf) : BiConsumer { var sum = 0 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 88ca6ffe1..aa44265db 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -87,6 +87,13 @@ open class Event( override fun hasTagWithContent(tagName: String) = tags.any { it.size > 1 && it[0] == tagName } + override fun forEachTaggedEvent(onEach: (eventId: HexKey) -> Unit) = + tags.forEach { + if (it.size > 1 && it[0] == "e") { + onEach(it[1]) + } + } + 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 0c5ce06f7..d4f3056b8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -117,6 +117,8 @@ interface EventInterface { fun hasTagWithContent(tagName: String): Boolean + fun forEachTaggedEvent(onEach: (eventId: HexKey) -> Unit) + fun taggedAddresses(): List fun taggedUsers(): List diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt index d205a743b..cc96ea072 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryRequestEvent.kt @@ -40,14 +40,14 @@ class NIP90ContentDiscoveryRequestEvent( const val KIND = 5300 fun create( - addressedDVM: String, + dvmPublicKey: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, ) { val content = "" val tags = mutableListOf>() - tags.add(arrayOf("p", addressedDVM)) + tags.add(arrayOf("p", dvmPublicKey)) tags.add(arrayOf("alt", "NIP90 Content Discovery request")) tags.add(arrayOf("client", "Amethyst")) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt index 8f76eb125..535bed7b6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP90ContentDiscoveryResponseEvent.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.events +import android.util.Log import androidx.compose.runtime.Immutable +import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -34,6 +36,33 @@ class NIP90ContentDiscoveryResponseEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient var events: List? = null + + fun innerTags(): List { + if (content.isEmpty()) { + return listOf() + } + + events?.let { + return it + } + + try { + events = + mapper.readValue>>(content).mapNotNull { + if (it.size > 1 && it[0] == "e") { + it[1] + } else { + null + } + } + } catch (e: Throwable) { + Log.w("GeneralList", "Error parsing the JSON ${e.message}") + } + + return events ?: listOf() + } + companion object { const val KIND = 6300 const val ALT = "NIP90 Content Discovery reply"