Refactoring the DVM codebase

Allows pull to refresh to request the job again.
This commit is contained in:
Vitor Pamplona 2024-05-17 17:02:04 -04:00
parent 8b052567c4
commit 9fb8d4821e
16 changed files with 335 additions and 329 deletions

View File

@ -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,

View File

@ -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<TypedFilter> {
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<TypedFilter> {
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<TypedFilter> {
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(

View File

@ -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),

View File

@ -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<Note>() {
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<Note> {
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<Note> = 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<Note>): Set<Note> {
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<Note>): Set<Note> {
// 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<Note> = 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<Note>): List<Note> {
return collection.toList() // collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@ -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<Note>() {
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<Note> {
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<Note>): Set<Note> {
// 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<Note>): List<Note> {

View File

@ -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,
)

View File

@ -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)) }
}
}

View File

@ -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 <NostrNIP90ContentDiscoveryFeedViewModel : ViewModel> create(modelClass: Class<NostrNIP90ContentDiscoveryFeedViewModel>): 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 <NostrNIP90StatusFeedViewModel : ViewModel> create(modelClass: Class<NostrNIP90StatusFeedViewModel>): NostrNIP90StatusFeedViewModel {
return NostrNIP90StatusFeedViewModel(account, dvmkey, requestid) as NostrNIP90StatusFeedViewModel
}
}
}
@Stable
class NostrDraftEventsFeedViewModel(val account: Account) :
FeedViewModel(DraftEventsFeedFilter(account)) {

View File

@ -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<Event?> {
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<DraftEvent, Note>(20) {

View File

@ -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<Note?>(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)
}
}

View File

@ -847,6 +847,6 @@
<string name="load_from_text">From Msg</string>
<string name="dvm_waiting_status">Waiting for DVM to reply</string>
<string name="dvm_no_status">DVM seems not to reply</string>
<string name="dvm_waiting_status">Job Requested, waiting for a reply</string>
<string name="dvm_requesting_job">Requesting Job from DVM</string>
</resources>

View File

@ -103,6 +103,15 @@ class LargeCache<K, V> {
return runner.results
}
fun maxOrNullOf(
filter: BiFilter<K, V>,
comparator: Comparator<V>,
): V? {
val runner = BiMaxOfCollector(filter, comparator)
innerForEach(runner)
return runner.maxV
}
fun sumOf(consumer: BiSumOf<K, V>): Int {
val runner = BiSumOfCollector(consumer)
innerForEach(runner)
@ -263,6 +272,23 @@ fun interface BiSumOf<K, V> {
): Int
}
class BiMaxOfCollector<K, V>(val filter: BiFilter<K, V>, val comparator: Comparator<V>) : BiConsumer<K, V> {
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<K, V>(val mapper: BiSumOf<K, V>) : BiConsumer<K, V> {
var sum = 0

View File

@ -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] }

View File

@ -117,6 +117,8 @@ interface EventInterface {
fun hasTagWithContent(tagName: String): Boolean
fun forEachTaggedEvent(onEach: (eventId: HexKey) -> Unit)
fun taggedAddresses(): List<ATag>
fun taggedUsers(): List<HexKey>

View File

@ -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<Array<String>>()
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)

View File

@ -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<HexKey>? = null
fun innerTags(): List<HexKey> {
if (content.isEmpty()) {
return listOf()
}
events?.let {
return it
}
try {
events =
mapper.readValue<Array<Array<String>>>(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"