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.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP17Factory import com.vitorpamplona.quartz.events.NIP17Factory
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.events.OtsEvent import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent 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( fun unwrap(
event: GiftWrapEvent, event: GiftWrapEvent,
onReady: (Event) -> Unit, 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.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch 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> { fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.liveDiscoveryFollowLists.value?.users?.toList() val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
@@ -463,8 +425,6 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
discoveryFeedChannel.typedFilters = discoveryFeedChannel.typedFilters =
createLiveStreamFilter() createLiveStreamFilter()
.plus(createNIP89Filter(listOf("5300"))) .plus(createNIP89Filter(listOf("5300")))
.plus(createNIP90ResponseFilter())
.plus(createNIP90StatusFilter())
.plus(createPublicChatFilter()) .plus(createPublicChatFilter())
.plus(createMarketplaceFilter()) .plus(createMarketplaceFilter())
.plus( .plus(

View File

@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent 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.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
@@ -171,6 +173,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
kinds = kinds =
listOf( listOf(
DeletionEvent.KIND, DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND,
), ),
tags = mapOf("e" to it.map { it.idHex }), tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it), 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.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.MuteListEvent import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.PeopleListEvent import com.vitorpamplona.quartz.events.PeopleListEvent
open class NIP90StatusFilter( open class NIP90ContentDiscoveryResponseFilter(
val account: Account, val account: Account,
val dvmkey: String, val dvmkey: String,
val request: String, val request: String,
) : AdditiveFeedFilter<Note>() { ) : AdditiveFeedFilter<Note>() {
var latestNote: Note? = null
override fun feedKey(): String { override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + followList() return account.userProfile().pubkeyHex + "-" + request
} }
open fun followList(): String { open fun followList(): String {
@@ -45,20 +47,41 @@ open class NIP90StatusFilter(
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) 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> { override fun feed(): List<Note> {
val params = buildFilterParams(account) val params = buildFilterParams(account)
val status = latestNote =
LocalCache.notes.filterIntoSet { _, it -> LocalCache.notes.maxOrNullOf(
val noteEvent = it.event filter = { idHex: String, note: Note ->
noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && acceptableEvent(note)
it.event!!.isTaggedEvent(request) },
// && it.event?.isTaggedUser(account.keyPair.pubKey.toHexKey()) == true // && params.match(noteEvent) comparator = createAtComparator,
} )
if (status.isNotEmpty()) {
return listOf(status.first()) val noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return listOf()
} else {
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> { protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
// val params = buildFilterParams(account) // val params = buildFilterParams(account)
val status = val maxNote = collection.filter { acceptableEvent(it) }.maxByOrNull { it.createdAt() ?: 0 } ?: return emptySet()
LocalCache.notes.filterIntoSet { _, it ->
val noteEvent = it.event if ((maxNote.createdAt() ?: 0) > (latestNote?.createdAt() ?: 0)) {
noteEvent is NIP90StatusEvent && it.event?.pubKey() == dvmkey && latestNote = maxNote
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 noteEvent = latestNote?.event as? NIP90ContentDiscoveryResponseEvent ?: return setOf()
return noteEvent.innerTags().mapNotNull {
LocalCache.checkGetOrCreateNote(it)
}.toSet()
} }
override fun sort(collection: Set<Note>): List<Note> { override fun sort(collection: Set<Note>): List<Note> {

View File

@@ -227,9 +227,9 @@ fun AppNavigation(
route.route, route.route,
route.arguments, route.arguments,
content = { content = {
it.arguments?.getString("id")?.let { it1 -> it.arguments?.getString("id")?.let { id ->
NIP90ContentDiscoveryScreen( NIP90ContentDiscoveryScreen(
DVMID = it1, dvmPublicKey = id,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )

View File

@@ -97,11 +97,24 @@ fun RefresheableBox(
viewModel: InvalidatableViewModel, viewModel: InvalidatableViewModel,
enablePullRefresh: Boolean = true, enablePullRefresh: Boolean = true,
content: @Composable () -> Unit, 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) } var refreshing by remember { mutableStateOf(false) }
val refresh = { val refresh = {
refreshing = true refreshing = true
viewModel.invalidateData() onRefresh()
refreshing = false refreshing = false
} }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
@@ -301,19 +314,3 @@ fun FeedEmpty(onRefresh: () -> Unit) {
OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) } 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.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryFilter import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter
import com.vitorpamplona.amethyst.ui.dal.NIP90StatusFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileAppRecommendationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
@@ -285,7 +284,7 @@ class NostrBookmarkPrivateFeedViewModel(val account: Account) :
@Stable @Stable
class NostrNIP90ContentDiscoveryFeedViewModel(val account: Account, val dvmkey: String, val requestid: String) : 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 { class Factory(val account: Account, val dvmkey: String, val requestid: String) : ViewModelProvider.Factory {
override fun <NostrNIP90ContentDiscoveryFeedViewModel : ViewModel> create(modelClass: Class<NostrNIP90ContentDiscoveryFeedViewModel>): NostrNIP90ContentDiscoveryFeedViewModel { override fun <NostrNIP90ContentDiscoveryFeedViewModel : ViewModel> create(modelClass: Class<NostrNIP90ContentDiscoveryFeedViewModel>): NostrNIP90ContentDiscoveryFeedViewModel {
return NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as 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 @Stable
class NostrDraftEventsFeedViewModel(val account: Account) : class NostrDraftEventsFeedViewModel(val account: Account) :
FeedViewModel(DraftEventsFeedFilter(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.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
import com.vitorpamplona.amethyst.service.CashuProcessor import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.service.HttpClientManager import com.vitorpamplona.amethyst.service.HttpClientManager
@@ -98,6 +99,7 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@@ -174,6 +176,25 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
account.reactTo(note, reaction) 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( fun reactToOrDelete(
note: Note, note: Note,
reaction: String, reaction: String,
@@ -1321,6 +1342,17 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
return note 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) val draftNoteCache = CachedDraftNotes(this)
class CachedDraftNotes(val accountViewModel: AccountViewModel) : GenericBaseCacheAsync<DraftEvent, Note>(20) { class CachedDraftNotes(val accountViewModel: AccountViewModel) : GenericBaseCacheAsync<DraftEvent, Note>(20) {

View File

@@ -20,137 +20,195 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn 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.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedEmptywithStatus
import com.vitorpamplona.amethyst.ui.screen.NostrNIP90ContentDiscoveryFeedViewModel 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.RefresheableBox
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState 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 @Composable
fun NIP90ContentDiscoveryScreen( fun NIP90ContentDiscoveryScreen(
DVMID: String, dvmPublicKey: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
) { ) {
var requestID = "" var requestEventID by
val thread = remember(dvmPublicKey) {
Thread { mutableStateOf<Note?>(null)
try {
NIP90ContentDiscoveryRequestEvent.create(DVMID, accountViewModel.account.signer) {
Client.send(it)
requestID = it.id
LocalCache.justConsume(it, null)
}
} catch (e: Exception) {
e.printStackTrace()
}
} }
thread.start() val onRefresh = {
thread.join() accountViewModel.requestDVMContentDiscovery(dvmPublicKey) {
requestEventID = it
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()
} }
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 @Composable
@OptIn(ExperimentalFoundationApi::class) fun ObserverContentDiscoveryResponse(
fun RenderNostrNIP90ContentDiscoveryScreen( dvmPublicKey: String,
dvmID: String?, dvmRequestId: Note,
onRefresh: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, 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, resultFeedViewModel: NostrNIP90ContentDiscoveryFeedViewModel,
statusFeedViewModel: NostrNIP90StatusFeedViewModel, onRefresh: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) { ) {
Column(Modifier.fillMaxHeight()) { 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) 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) 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() // TODO (Optional) otherwise we have the NIP89 info in (note.event as AppDefinitionEvent).appMetaData()
// Text(text = dvminfo) SaveableFeedState(resultFeedViewModel, null) { listState ->
// TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation
HorizontalPager(state = pagerState) { RenderFeedState(
RefresheableBox(resultFeedViewModel, false) { resultFeedViewModel,
SaveableFeedState(resultFeedViewModel, null) { listState -> accountViewModel,
// TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation listState,
RenderFeedState( nav,
resultFeedViewModel, null,
accountViewModel, onEmpty = {
listState, // TODO (Optional) Maybe also show some dvm image/text while waiting for the notes in this custom component
nav, FeedEmpty {
null, onRefresh()
onEmpty = { }
// TODO (Optional) Maybe also show some dvm image/text while waiting for the notes in this custom component },
FeedEmptywithStatus(status = dvmState) { )
}
},
)
}
}
} }
} }
} }
@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="load_from_text">From Msg</string>
<string name="dvm_waiting_status">Waiting for DVM to reply</string> <string name="dvm_waiting_status">Job Requested, waiting for a reply</string>
<string name="dvm_no_status">DVM seems not to reply</string> <string name="dvm_requesting_job">Requesting Job from DVM</string>
</resources> </resources>

View File

@@ -103,6 +103,15 @@ class LargeCache<K, V> {
return runner.results 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 { fun sumOf(consumer: BiSumOf<K, V>): Int {
val runner = BiSumOfCollector(consumer) val runner = BiSumOfCollector(consumer)
innerForEach(runner) innerForEach(runner)
@@ -263,6 +272,23 @@ fun interface BiSumOf<K, V> {
): Int ): 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> { class BiSumOfCollector<K, V>(val mapper: BiSumOf<K, V>) : BiConsumer<K, V> {
var sum = 0 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 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 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] } 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 hasTagWithContent(tagName: String): Boolean
fun forEachTaggedEvent(onEach: (eventId: HexKey) -> Unit)
fun taggedAddresses(): List<ATag> fun taggedAddresses(): List<ATag>
fun taggedUsers(): List<HexKey> fun taggedUsers(): List<HexKey>

View File

@@ -40,14 +40,14 @@ class NIP90ContentDiscoveryRequestEvent(
const val KIND = 5300 const val KIND = 5300
fun create( fun create(
addressedDVM: String, dvmPublicKey: String,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit, onReady: (NIP90ContentDiscoveryRequestEvent) -> Unit,
) { ) {
val content = "" val content = ""
val tags = mutableListOf<Array<String>>() 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("alt", "NIP90 Content Discovery request"))
tags.add(arrayOf("client", "Amethyst")) tags.add(arrayOf("client", "Amethyst"))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)

View File

@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.quartz.events package com.vitorpamplona.quartz.events
import android.util.Log
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -34,6 +36,33 @@ class NIP90ContentDiscoveryResponseEvent(
content: String, content: String,
sig: HexKey, sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : 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 { companion object {
const val KIND = 6300 const val KIND = 6300
const val ALT = "NIP90 Content Discovery reply" const val ALT = "NIP90 Content Discovery reply"