Migrates contact list management to addressable notes
Creates new observable flows for LocalCache.
This commit is contained in:
@@ -147,10 +147,8 @@ fun debugState(context: Context) {
|
||||
)
|
||||
Log.d(
|
||||
STATE_DUMP_TAG,
|
||||
"Observable Events: " +
|
||||
LocalCache.observablesByKindAndETag.size +
|
||||
" / " +
|
||||
LocalCache.observablesByKindAndAuthor.size,
|
||||
"Observables: " +
|
||||
LocalCache.observables.size,
|
||||
)
|
||||
|
||||
Log.d(
|
||||
|
||||
@@ -32,10 +32,11 @@ import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
|
||||
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.observables.LatestByKindAndAuthor
|
||||
import com.vitorpamplona.amethyst.model.observables.LatestByKindWithETag
|
||||
import com.vitorpamplona.amethyst.model.observables.EventListMatchingFilter
|
||||
import com.vitorpamplona.amethyst.model.observables.NoteListMatchingFilter
|
||||
import com.vitorpamplona.amethyst.service.BundledInsert
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.amethyst.ui.note.dateFormatter
|
||||
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
@@ -62,6 +63,9 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isRegular
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
@@ -72,13 +76,13 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.forEachTaggedEventId
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
|
||||
@@ -197,16 +201,21 @@ import com.vitorpamplona.quartz.utils.cache.LargeCache
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.SortedSet
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
interface ILocalCache {
|
||||
@@ -216,6 +225,15 @@ interface ILocalCache {
|
||||
) {}
|
||||
}
|
||||
|
||||
interface Observable {
|
||||
fun new(
|
||||
event: Event,
|
||||
note: Note,
|
||||
)
|
||||
|
||||
fun remove(note: Note)
|
||||
}
|
||||
|
||||
object LocalCache : ILocalCache, ICacheProvider {
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
@@ -234,76 +252,103 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
val deletionIndex = DeletionIndex()
|
||||
|
||||
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
|
||||
val observablesByKindAndAuthor = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>>(10)
|
||||
val observables = ConcurrentHashMap<Observable, Observable>(10)
|
||||
|
||||
fun <T : Event> observeETag(
|
||||
kind: Int,
|
||||
eventId: HexKey,
|
||||
scope: CoroutineScope,
|
||||
): LatestByKindWithETag<T> {
|
||||
var eTagList = observablesByKindAndETag.get(kind)
|
||||
|
||||
if (eTagList == null) {
|
||||
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
|
||||
observablesByKindAndETag.put(kind, eTagList)
|
||||
}
|
||||
|
||||
val value = eTagList.get(eventId)
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
fun Filter.match(note: Note): Boolean {
|
||||
val event = note.event
|
||||
return if (event != null) {
|
||||
match(event)
|
||||
} else {
|
||||
val newObject = LatestByKindWithETag<T>(kind, eventId) as LatestByKindWithETag<Event>
|
||||
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
|
||||
if (obj == newObject) {
|
||||
// initialize
|
||||
scope.launch(Dispatchers.IO) {
|
||||
obj.init()
|
||||
false
|
||||
}
|
||||
}
|
||||
obj
|
||||
} as LatestByKindWithETag<T>
|
||||
|
||||
fun filter(filter: Filter): SortedSet<Note> {
|
||||
val byKinds = filter.kinds?.filter { it.isAddressable() || it.isReplaceable() }
|
||||
|
||||
val addressableMatches =
|
||||
if (!byKinds.isNullOrEmpty()) {
|
||||
val byAuthors = filter.authors
|
||||
if (!byAuthors.isNullOrEmpty()) {
|
||||
// optimized
|
||||
byKinds.flatMap { kind ->
|
||||
byAuthors.flatMap { pubkey ->
|
||||
addressables.filter(kind, pubkey) { _, note ->
|
||||
filter.match(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> observeAuthor(
|
||||
kind: Int,
|
||||
pubkey: HexKey,
|
||||
scope: CoroutineScope,
|
||||
): LatestByKindAndAuthor<T> {
|
||||
var authorObsList = observablesByKindAndAuthor.get(kind)
|
||||
|
||||
if (authorObsList == null) {
|
||||
authorObsList = ConcurrentHashMap<HexKey, LatestByKindAndAuthor<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindAndAuthor<Event>>
|
||||
observablesByKindAndAuthor.put(kind, authorObsList)
|
||||
}
|
||||
|
||||
val value = authorObsList.get(pubkey)
|
||||
|
||||
return if (value != null) {
|
||||
value
|
||||
} else {
|
||||
val newObject = LatestByKindAndAuthor<T>(kind, pubkey) as LatestByKindAndAuthor<Event>
|
||||
val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject
|
||||
if (obj == newObject) {
|
||||
// initialize
|
||||
scope.launch(Dispatchers.IO) {
|
||||
obj.init()
|
||||
// optimized
|
||||
byKinds.flatMap { kind ->
|
||||
addressables.filter(kind) { _, note ->
|
||||
filter.match(note)
|
||||
}
|
||||
}
|
||||
obj
|
||||
} as LatestByKindAndAuthor<T>
|
||||
}
|
||||
|
||||
private fun updateObservables(event: Event) {
|
||||
observablesByKindAndETag[event.kind]?.let { observablesOfKind ->
|
||||
event.forEachTaggedEventId {
|
||||
observablesOfKind[it]?.updateIfMatches(event)
|
||||
} else {
|
||||
addressables.filter { _, note ->
|
||||
filter.match(note)
|
||||
}
|
||||
}
|
||||
|
||||
observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event)
|
||||
val noteMatches =
|
||||
notes.filter { _, note ->
|
||||
val event = note.event
|
||||
if (event != null && event.kind.isRegular()) {
|
||||
filter.match(event)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val limit = filter.limit
|
||||
|
||||
val limitedSet =
|
||||
if (limit != null) {
|
||||
(addressableMatches + noteMatches).take(limit)
|
||||
} else {
|
||||
(addressableMatches + noteMatches)
|
||||
}
|
||||
|
||||
return limitedSet.toSortedSet(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
fun observeNotes(filter: Filter): Flow<List<Note>> =
|
||||
callbackFlow {
|
||||
val newFilter =
|
||||
NoteListMatchingFilter(filter, this@LocalCache) {
|
||||
trySend(it)
|
||||
}
|
||||
|
||||
newFilter.init()
|
||||
|
||||
observables.put(newFilter, newFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(newFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
fun observeEvents(filter: Filter): Flow<List<Event>> =
|
||||
callbackFlow {
|
||||
val cachedFilter =
|
||||
EventListMatchingFilter(filter, this@LocalCache) {
|
||||
trySend(it)
|
||||
}
|
||||
|
||||
cachedFilter.init()
|
||||
|
||||
observables.put(cachedFilter, cachedFilter)
|
||||
|
||||
awaitClose {
|
||||
observables.remove(cachedFilter)
|
||||
}
|
||||
}.buffer(kotlinx.coroutines.channels.Channel.CONFLATED)
|
||||
|
||||
fun <T : Event> observeLatestEvent(filter: Filter) = observeEvents(filter).map { it.firstNotNullOfOrNull { it as? T } }
|
||||
|
||||
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
|
||||
@@ -334,6 +379,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
return count
|
||||
}
|
||||
|
||||
fun countContactLists(predicate: (ContactListEvent) -> Boolean): Int {
|
||||
var count = 0
|
||||
addressables.filter(ContactListEvent.KIND).forEach { note ->
|
||||
val event = note.event as? ContactListEvent
|
||||
if (event != null && predicate(event)) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
fun getAddressableNoteIfExists(key: String): AddressableNote? = Address.parse(key)?.let { addressables.get(it) }
|
||||
|
||||
fun getAddressableNoteIfExists(address: Address): AddressableNote? = addressables.get(address)
|
||||
@@ -522,25 +576,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
event: ContactListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
): Boolean {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
|
||||
// avoids processing empty contact lists.
|
||||
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !event.tags.isEmpty() && (wasVerified || justVerify(event))) {
|
||||
val needsToUpdateFollowers = user.updateContactList(event)
|
||||
// Log.d("CL", "Consumed contact list ${user.toNostrUri()} ${event.relays()?.size}")
|
||||
|
||||
needsToUpdateFollowers.forEach {
|
||||
getUserIfExists(it)?.flowSet?.followers?.invalidateData()
|
||||
}
|
||||
|
||||
updateObservables(event)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: BookmarkListEvent,
|
||||
@@ -2374,22 +2410,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
notes.forEach { _, it -> it.clearFlow() }
|
||||
addressables.forEach { _, it -> it.clearFlow() }
|
||||
users.forEach { _, it -> it.clearFlow() }
|
||||
|
||||
observablesByKindAndETag.forEach { _, list ->
|
||||
list.forEach { key, value ->
|
||||
if (value.canDelete()) {
|
||||
list.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observablesByKindAndAuthor.forEach { _, list ->
|
||||
list.forEach { key, value ->
|
||||
if (value.canDelete()) {
|
||||
list.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenMessagesChannel(
|
||||
@@ -2670,24 +2690,6 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
|
||||
fun pruneContactLists(loggedIn: Set<HexKey>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
var removingContactList = 0
|
||||
users.forEach { _, user ->
|
||||
if (
|
||||
user.pubkeyHex !in loggedIn &&
|
||||
(user.flowSet == null || user.flowSet?.isInUse() == false) &&
|
||||
user.latestContactList != null
|
||||
) {
|
||||
user.latestContactList = null
|
||||
removingContactList++
|
||||
}
|
||||
}
|
||||
|
||||
println("PRUNE: $removingContactList contact lists")
|
||||
}
|
||||
|
||||
override fun markAsSeen(
|
||||
eventId: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -2708,11 +2710,23 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
|
||||
private fun refreshNewNoteObservers(newNote: Note) {
|
||||
val event = newNote.event as Event
|
||||
updateObservables(event)
|
||||
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { t, u ->
|
||||
u.new(event, newNote)
|
||||
}
|
||||
|
||||
observables.forEach(observableBiConsumer)
|
||||
live.newNote(newNote)
|
||||
}
|
||||
|
||||
private fun refreshDeletedNoteObservers(newNote: Note) {
|
||||
val observableBiConsumer =
|
||||
java.util.function.BiConsumer<Observable, Observable> { t, u ->
|
||||
u.remove(newNote)
|
||||
}
|
||||
|
||||
observables.forEach(observableBiConsumer)
|
||||
live.removedNote(newNote)
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -21,10 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.model.nip02FollowLists
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
@@ -52,14 +52,19 @@ class Kind3FollowListState(
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun getFollowListFlow(): StateFlow<UserState> = user.flow().follows.stateFlow
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
|
||||
val note = cache.getOrCreateAddressableNote(getFollowListAddress())
|
||||
|
||||
fun getFollowListEvent(): ContactListEvent? = user.latestContactList
|
||||
fun getFollowListAddress() = ContactListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFollowListFlow(): StateFlow<NoteState> = note.flow().metadata.stateFlow
|
||||
|
||||
fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val innerFlow: Flow<Kind3Follows> =
|
||||
getFollowListFlow().transformLatest {
|
||||
emit(buildKind3Follows(it.user.latestContactList ?: settings.backupContactList))
|
||||
emit(buildKind3Follows(it.note.event as? ContactListEvent ?: settings.backupContactList))
|
||||
}
|
||||
|
||||
val flow =
|
||||
@@ -149,7 +154,9 @@ class Kind3FollowListState(
|
||||
Log.d("AccountRegisterObservers", "Kind 3 Collector Start")
|
||||
getFollowListFlow().collect {
|
||||
Log.d("AccountRegisterObservers", "Updating Kind 3 ${signer.pubKey}")
|
||||
settings.updateContactListTo(it.user.latestContactList)
|
||||
(it.note.event as? ContactListEvent)?.let {
|
||||
settings.updateContactListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.Observable
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
|
||||
/**
|
||||
* Creates a list of events (regular and addressable)
|
||||
* that is updated every time a new event that matches
|
||||
* the filter is received, including addressables.
|
||||
*/
|
||||
class EventListMatchingFilter(
|
||||
private val filter: Filter,
|
||||
private val cache: LocalCache,
|
||||
private val update: (List<Event>) -> Unit,
|
||||
) : Observable {
|
||||
// Keeping this here blocks it from being cleared from memory
|
||||
var currentResults: SortedSet<Note> = sortedSetOf<Note>(DefaultFeedOrder)
|
||||
|
||||
override fun new(
|
||||
event: Event,
|
||||
note: Note,
|
||||
) {
|
||||
if (filter.match(event)) {
|
||||
currentResults.add(note)
|
||||
val limit = filter.limit
|
||||
if (limit != null && currentResults.size > limit) {
|
||||
currentResults.remove(currentResults.last())
|
||||
}
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
}
|
||||
}
|
||||
|
||||
override fun remove(note: Note) {
|
||||
if (currentResults.remove(note)) {
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
}
|
||||
}
|
||||
|
||||
fun init() {
|
||||
currentResults = cache.filter(filter)
|
||||
update(currentResults.mapNotNull { it.event })
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 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.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class LatestByKindAndAuthor<T : Event>(
|
||||
private val kind: Int,
|
||||
private val pubkey: String,
|
||||
) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.pubKey == pubkey
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
|
||||
|
||||
fun restart() {
|
||||
val latestNote =
|
||||
if ((kind in 10000..19999) || (kind in 30000..39999)) {
|
||||
LocalCache.addressables
|
||||
.maxOrNullOf(
|
||||
filter = { address: Address, note: AddressableNote ->
|
||||
address.kind == kind && address.pubKeyHex == pubkey
|
||||
},
|
||||
comparator = CreatedAtComparatorAddresses,
|
||||
)?.event as? T
|
||||
} else {
|
||||
LocalCache.notes
|
||||
.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind == kind && it.pubKey == pubkey
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
}
|
||||
|
||||
if (_latest.value != latestNote) {
|
||||
_latest.tryEmit(latestNote)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
restart()
|
||||
}
|
||||
}
|
||||
+33
-32
@@ -22,48 +22,49 @@ package com.vitorpamplona.amethyst.model.observables
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.Observable
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import java.util.SortedSet
|
||||
|
||||
class LatestByKindWithETag<T : Event>(
|
||||
private val kind: Int,
|
||||
private val eTag: String,
|
||||
) {
|
||||
private val _latest = MutableStateFlow<T?>(null)
|
||||
val latest = _latest.asStateFlow()
|
||||
/**
|
||||
* Creates a list of notes (regular and addressable)
|
||||
* that only gets updated when a new note appears.
|
||||
*
|
||||
* New versions of addressables do not update the list
|
||||
*/
|
||||
class NoteListMatchingFilter(
|
||||
private val filter: Filter,
|
||||
private val cache: LocalCache,
|
||||
private val update: (List<Note>) -> Unit,
|
||||
) : Observable {
|
||||
var currentResults: SortedSet<Note> = sortedSetOf<Note>(DefaultFeedOrder)
|
||||
|
||||
fun matches(event: T) = event.kind == kind && event.isTaggedEvent(eTag)
|
||||
override fun new(
|
||||
event: Event,
|
||||
note: Note,
|
||||
) {
|
||||
if (filter.match(event)) {
|
||||
if (currentResults.add(note)) {
|
||||
val limit = filter.limit
|
||||
if (limit != null && currentResults.size > limit) {
|
||||
currentResults.remove(currentResults.last())
|
||||
}
|
||||
|
||||
fun updateIfMatches(event: T) {
|
||||
if (matches(event)) {
|
||||
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
|
||||
_latest.tryEmit(event)
|
||||
update(currentResults.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = _latest.subscriptionCount.value == 0
|
||||
|
||||
fun restart() {
|
||||
val latestNote =
|
||||
LocalCache.notes
|
||||
.maxOrNullOf(
|
||||
filter = { idHex: String, note: Note ->
|
||||
note.event?.let {
|
||||
it.kind == kind && it.isTaggedEvent(eTag)
|
||||
} == true
|
||||
},
|
||||
comparator = CreatedAtComparator,
|
||||
)?.event as? T
|
||||
|
||||
if (_latest.value != latestNote) {
|
||||
_latest.tryEmit(latestNote)
|
||||
override fun remove(note: Note) {
|
||||
if (currentResults.remove(note)) {
|
||||
update(currentResults.toList())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun init() {
|
||||
restart()
|
||||
fun init() {
|
||||
currentResults = cache.filter(filter)
|
||||
update(currentResults.toList())
|
||||
}
|
||||
}
|
||||
-1
@@ -46,7 +46,6 @@ class MemoryTrimmingService(
|
||||
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneContactLists(accounts)
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
cache.prunePastVersionsOfReplaceables()
|
||||
cache.pruneExpiredEvents()
|
||||
|
||||
+25
-67
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -45,6 +45,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
@@ -178,21 +179,6 @@ fun observeUserPicture(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserFollows(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<UserState?> {
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return user
|
||||
.flow()
|
||||
.follows.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun observeUserFollowCount(
|
||||
@@ -202,17 +188,26 @@ fun observeUserFollowCount(
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
val note =
|
||||
remember(user) {
|
||||
accountViewModel.follows(user)
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
note
|
||||
.flow()
|
||||
.follows.stateFlow
|
||||
.mapLatest { it.user.transientFollowCount() ?: 0 }
|
||||
.metadata.stateFlow
|
||||
.mapLatest { noteState ->
|
||||
(noteState.note.event as? ContactListEvent)?.followCount() ?: 0
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(user.transientFollowCount() ?: 0)
|
||||
return flow.collectAsStateWithLifecycle(
|
||||
(note.event as? ContactListEvent)?.followCount() ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@@ -318,48 +313,6 @@ fun observeUserBookmarkCount(
|
||||
return flow.collectAsStateWithLifecycle(0)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun observeUserFollowers(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<UserState?> {
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
return user
|
||||
.flow()
|
||||
.followers.stateFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun observeUserFollowerCount(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<Int> {
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user, accountViewModel)
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
remember(user) {
|
||||
user
|
||||
.flow()
|
||||
.followers.stateFlow
|
||||
.sample(200)
|
||||
.mapLatest { userState ->
|
||||
LocalCache.countUsers { _, user ->
|
||||
user.latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(0)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun observeUserIsFollowing(
|
||||
@@ -370,21 +323,26 @@ fun observeUserIsFollowing(
|
||||
// Subscribe in the relay for changes in the metadata of this user.
|
||||
UserFinderFilterAssemblerSubscription(user1, accountViewModel)
|
||||
|
||||
val note =
|
||||
remember(user1) {
|
||||
accountViewModel.follows(user1)
|
||||
}
|
||||
|
||||
// Subscribe in the LocalCache for changes that arrive in the device
|
||||
val flow =
|
||||
remember(user1) {
|
||||
user1
|
||||
note
|
||||
.flow()
|
||||
.follows.stateFlow
|
||||
.sample(1000)
|
||||
.metadata.stateFlow
|
||||
.mapLatest { userState ->
|
||||
userState.user.isFollowing(user2)
|
||||
val event = userState.note.event as? ContactListEvent
|
||||
event?.isFollowing(user2.pubkeyHex) ?: false
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
return flow.collectAsStateWithLifecycle(
|
||||
user1.isFollowing(user2),
|
||||
(note.event as? ContactListEvent)?.isFollowing(user2.pubkeyHex) ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,13 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.ui.notifications.Card
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
val DefaultFeedOrder: Comparator<Note> =
|
||||
compareByDescending<Note> { it.createdAt() }.thenBy { it.idHex }
|
||||
|
||||
val DefaultFeedOrderEvent: Comparator<Event> =
|
||||
compareByDescending<Event> { it.createdAt }.thenBy { it.id }
|
||||
|
||||
val DefaultFeedOrderCard: Comparator<Card> =
|
||||
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
|
||||
|
||||
+25
-31
@@ -27,7 +27,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -42,8 +41,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
@@ -62,6 +59,9 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -111,8 +111,7 @@ fun NoteDropDownMenu(
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
|
||||
DropDownParams(
|
||||
isFollowingAuthor = false,
|
||||
isPrivateBookmarkNote = false,
|
||||
@@ -122,7 +121,6 @@ fun NoteDropDownMenu(
|
||||
showSensitiveContent = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val wantsToEditPost =
|
||||
remember {
|
||||
@@ -153,15 +151,8 @@ fun NoteDropDownMenu(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
val actContext = LocalContext.current
|
||||
|
||||
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (!state.isFollowingAuthor) {
|
||||
@@ -374,30 +365,33 @@ fun NoteDropDownMenu(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchBookmarksFollowsAndAccount(
|
||||
fun observeBookmarksFollowsAndAccount(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onNew: (DropDownParams) -> Unit,
|
||||
) {
|
||||
val followState by observeUserFollows(accountViewModel.userProfile(), accountViewModel)
|
||||
val bookmarkState by observeUserBookmarks(accountViewModel.userProfile(), accountViewModel)
|
||||
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
|
||||
val newState =
|
||||
) = remember(note) {
|
||||
combine(
|
||||
accountViewModel.account.kind3FollowList.flow,
|
||||
accountViewModel.account.bookmarkState.bookmarks,
|
||||
accountViewModel.showSensitiveContent(),
|
||||
) { follows, bookmarks, showSensitiveContent ->
|
||||
DropDownParams(
|
||||
isFollowingAuthor = accountViewModel.isFollowing(note.author),
|
||||
isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note),
|
||||
isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note),
|
||||
isFollowingAuthor = note.author?.pubkeyHex in follows.authors,
|
||||
isPrivateBookmarkNote = note in bookmarks.private,
|
||||
isPublicBookmarkNote = note in bookmarks.public,
|
||||
isLoggedUser = accountViewModel.isLoggedUser(note.author),
|
||||
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
onNew(
|
||||
newState,
|
||||
}.onStart {
|
||||
emit(
|
||||
DropDownParams(
|
||||
isFollowingAuthor = note.author?.pubkeyHex?.let { accountViewModel.account.isFollowing(it) } ?: false,
|
||||
isPrivateBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.private,
|
||||
isPublicBookmarkNote = note in accountViewModel.account.bookmarkState.bookmarks.value.public,
|
||||
isLoggedUser = accountViewModel.isLoggedUser(note.author),
|
||||
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
|
||||
showSensitiveContent = accountViewModel.showSensitiveContent().value,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
||||
+3
-10
@@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.EmptyOtsResolverBuilder
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
|
||||
@@ -296,16 +297,6 @@ class AccountViewModel(
|
||||
|
||||
fun userProfile(): User = account.userProfile()
|
||||
|
||||
fun <T : Event> observeByETag(
|
||||
kind: Int,
|
||||
eTag: HexKey,
|
||||
): StateFlow<T?> = LocalCache.observeETag<T>(kind = kind, eventId = eTag, viewModelScope).latest
|
||||
|
||||
fun <T : Event> observeByAuthor(
|
||||
kind: Int,
|
||||
pubkeyHex: HexKey,
|
||||
): StateFlow<T?> = LocalCache.observeAuthor<T>(kind = kind, pubkey = pubkeyHex, viewModelScope).latest
|
||||
|
||||
fun reactToOrDelete(
|
||||
note: Note,
|
||||
reaction: String,
|
||||
@@ -767,6 +758,8 @@ class AccountViewModel(
|
||||
|
||||
fun removeFromMediaGallery(note: Note) = launchSigner { account.removeFromGallery(note) }
|
||||
|
||||
fun follows(user: User): Note = LocalCache.getOrCreateAddressableNote(ContactListEvent.createAddress(user.pubkeyHex))
|
||||
|
||||
fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
|
||||
|
||||
fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex))
|
||||
|
||||
+3
-11
@@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
@@ -45,7 +46,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.WatchBookmarksFollowsAndAccount
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
|
||||
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -106,8 +107,7 @@ fun BookmarkGroupItemOptionsMenu(
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
|
||||
DropDownParams(
|
||||
isFollowingAuthor = false,
|
||||
isPrivateBookmarkNote = false,
|
||||
@@ -117,7 +117,6 @@ fun BookmarkGroupItemOptionsMenu(
|
||||
showSensitiveContent = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val wantsToEditPost =
|
||||
remember {
|
||||
@@ -148,15 +147,8 @@ fun BookmarkGroupItemOptionsMenu(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
val actContext = LocalContext.current
|
||||
|
||||
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(if (isBookmarkItemPrivate) R.string.move_bookmark_to_public_label else R.string.move_bookmark_to_private_label)) },
|
||||
|
||||
+18
-6
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
|
||||
@@ -177,13 +178,17 @@ fun ObserverContentDiscoveryResponse(
|
||||
|
||||
val resultFlow =
|
||||
remember(dvmRequestId) {
|
||||
accountViewModel.observeByETag<NIP90ContentDiscoveryResponseEvent>(
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
dvmRequestId.idHex,
|
||||
accountViewModel.account.cache
|
||||
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
|
||||
tags = mapOf("e" to listOf(dvmRequestId.idHex)),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val latestResponse by resultFlow.collectAsStateWithLifecycle()
|
||||
val latestResponse by resultFlow.collectAsStateWithLifecycle(null)
|
||||
val myResponse = latestResponse
|
||||
|
||||
if (myResponse != null) {
|
||||
@@ -214,10 +219,17 @@ fun ObserverDvmStatusResponse(
|
||||
) {
|
||||
val statusFlow =
|
||||
remember(dvmRequestId) {
|
||||
accountViewModel.observeByETag<NIP90StatusEvent>(NIP90StatusEvent.KIND, dvmRequestId)
|
||||
accountViewModel.account.cache
|
||||
.observeLatestEvent<NIP90StatusEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90StatusEvent.KIND),
|
||||
tags = mapOf("e" to listOf(dvmRequestId)),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val latestStatus by statusFlow.collectAsStateWithLifecycle()
|
||||
val latestStatus by statusFlow.collectAsStateWithLifecycle(null)
|
||||
|
||||
// TODO: Make a good splash screen with loading animation for this DVM.
|
||||
if (latestStatus != null) {
|
||||
|
||||
+45
-28
@@ -31,43 +31,44 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||
|
||||
class NotificationFeedFilter(
|
||||
val account: Account,
|
||||
@@ -76,15 +77,42 @@ class NotificationFeedFilter(
|
||||
val ADDRESSABLE_KINDS =
|
||||
listOf(
|
||||
AudioTrackEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
CalendarDateSlotEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
ClassifiedsEvent.KIND,
|
||||
LiveActivitiesEvent.KIND,
|
||||
LongTextNoteEvent.KIND,
|
||||
NipTextEvent.KIND,
|
||||
VideoVerticalEvent.KIND,
|
||||
VideoHorizontalEvent.KIND,
|
||||
WikiNoteEvent.KIND,
|
||||
)
|
||||
|
||||
val NOTIFICATION_KINDS =
|
||||
setOf(
|
||||
BadgeAwardEvent.KIND,
|
||||
ChatMessageEvent.KIND,
|
||||
ChatMessageEncryptedFileHeaderEvent.KIND,
|
||||
CommentEvent.KIND,
|
||||
GenericRepostEvent.KIND,
|
||||
GitIssueEvent.KIND,
|
||||
GitPatchEvent.KIND,
|
||||
HighlightEvent.KIND,
|
||||
TextNoteEvent.KIND,
|
||||
ReactionEvent.KIND,
|
||||
RepostEvent.KIND,
|
||||
LnZapEvent.KIND,
|
||||
LiveActivitiesChatMessageEvent.KIND,
|
||||
PictureEvent.KIND,
|
||||
PollNoteEvent.KIND,
|
||||
PrivateDmEvent.KIND,
|
||||
PublicMessageEvent.KIND,
|
||||
VideoNormalEvent.KIND,
|
||||
VideoShortEvent.KIND,
|
||||
VoiceEvent.KIND,
|
||||
VoiceReplyEvent.KIND,
|
||||
) + ADDRESSABLE_KINDS
|
||||
}
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value
|
||||
@@ -150,18 +178,7 @@ class NotificationFeedFilter(
|
||||
}
|
||||
}
|
||||
|
||||
return noteEvent !is ChannelCreateEvent &&
|
||||
noteEvent !is ChannelMetadataEvent &&
|
||||
noteEvent !is LnZapRequestEvent &&
|
||||
noteEvent !is BadgeDefinitionEvent &&
|
||||
noteEvent !is BadgeProfilesEvent &&
|
||||
noteEvent !is NIP90ContentDiscoveryResponseEvent &&
|
||||
noteEvent !is NIP90StatusEvent &&
|
||||
noteEvent !is NIP90ContentDiscoveryRequestEvent &&
|
||||
noteEvent !is GiftWrapEvent &&
|
||||
noteEvent !is PrivateTagArrayEvent &&
|
||||
noteEvent !is LnZapPaymentRequestEvent &&
|
||||
noteEvent !is LnZapPaymentResponseEvent &&
|
||||
return noteEvent?.kind in NOTIFICATION_KINDS &&
|
||||
(noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) &&
|
||||
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
|
||||
noteEvent?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
|
||||
+2
-4
@@ -263,8 +263,6 @@ fun ProfileScreen(
|
||||
WatchLifecycleAndUpdateModel(threadsViewModel)
|
||||
WatchLifecycleAndUpdateModel(repliesViewModel)
|
||||
WatchLifecycleAndUpdateModel(mutualViewModel)
|
||||
WatchLifecycleAndUpdateModel(followsFeedViewModel)
|
||||
WatchLifecycleAndUpdateModel(followersFeedViewModel)
|
||||
WatchLifecycleAndUpdateModel(appRecommendations)
|
||||
WatchLifecycleAndUpdateModel(bookmarksFeedViewModel)
|
||||
WatchLifecycleAndUpdateModel(galleryFeedViewModel)
|
||||
@@ -452,8 +450,8 @@ private fun CreateAndRenderPages(
|
||||
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
|
||||
2 -> TabMutualConversations(mutualViewModel, accountViewModel, nav)
|
||||
3 -> TabGallery(galleryFeedViewModel, accountViewModel, nav)
|
||||
4 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
|
||||
5 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
|
||||
4 -> TabFollows(followsFeedViewModel, accountViewModel, nav)
|
||||
5 -> TabFollowers(followersFeedViewModel, accountViewModel, nav)
|
||||
6 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
|
||||
7 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
|
||||
8 -> TabFollowedTags(baseUser, accountViewModel, nav)
|
||||
|
||||
+25
-21
@@ -22,38 +22,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal.UserProfileFollowersUserFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
|
||||
@Composable
|
||||
fun TabFollowers(
|
||||
baseUser: User,
|
||||
feedViewModel: UserFeedViewModel,
|
||||
followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchFollowerChanges(baseUser, feedViewModel, accountViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
|
||||
val items by followersFeedViewModel.followersFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = FeedPadding,
|
||||
state = rememberLazyListState(),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchFollowerChanges(
|
||||
baseUser: User,
|
||||
feedViewModel: UserFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val userState by observeUserFollowers(baseUser, accountViewModel)
|
||||
|
||||
LaunchedEffect(userState) { feedViewModel.invalidateData() }
|
||||
}
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
|
||||
class UserProfileFollowersFeedFilter(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
) : FeedFilter<User>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
|
||||
override fun feed(): List<User> =
|
||||
LocalCache.users.filter { _, it ->
|
||||
it.isFollowing(user) && !account.isHidden(it)
|
||||
}
|
||||
|
||||
override fun limit() = 400
|
||||
}
|
||||
+46
-2
@@ -23,15 +23,59 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers.dal
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Stable
|
||||
class UserProfileFollowersUserFeedViewModel(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
) : UserFeedViewModel(UserProfileFollowersFeedFilter(user, account)) {
|
||||
) : ViewModel() {
|
||||
val followerFilter =
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
tags = mapOf("p" to listOf(user.pubkeyHex)),
|
||||
)
|
||||
|
||||
val sortingModel: Comparator<User> =
|
||||
compareBy(
|
||||
{ !account.isFollowing(it) },
|
||||
{ it.pubkeyHex },
|
||||
)
|
||||
|
||||
fun List<Event>.toNonHiddenOwners(): List<User> =
|
||||
mapNotNull { event ->
|
||||
if (!account.isHidden(event.pubKey)) {
|
||||
account.cache.getOrCreateUser(event.pubKey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val followersFlow: StateFlow<List<User>> =
|
||||
account.cache
|
||||
.observeEvents(followerFilter)
|
||||
.sample(500)
|
||||
.map { followerContactLists ->
|
||||
followerContactLists.toNonHiddenOwners().sortedWith(sortingModel)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
initialValue = emptyList(),
|
||||
started = SharingStarted.Lazily,
|
||||
)
|
||||
|
||||
class Factory(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
|
||||
+25
-21
@@ -22,38 +22,42 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal.UserProfileFollowsUserFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
|
||||
@Composable
|
||||
fun TabFollows(
|
||||
baseUser: User,
|
||||
feedViewModel: UserFeedViewModel,
|
||||
followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
WatchFollowChanges(baseUser, feedViewModel, accountViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
|
||||
val items by followsFeedViewModel.followsFlow.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = FeedPadding,
|
||||
state = rememberLazyListState(),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item -> item.pubkeyHex }) { _, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchFollowChanges(
|
||||
baseUser: User,
|
||||
feedViewModel: UserFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val userState by observeUserFollows(baseUser, accountViewModel)
|
||||
|
||||
LaunchedEffect(userState) { feedViewModel.invalidateData() }
|
||||
}
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
|
||||
class UserProfileFollowsFeedFilter(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
) : FeedFilter<User>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
|
||||
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
val contactList = user.latestContactList ?: return emptyList()
|
||||
|
||||
val previousList = cache[contactList]
|
||||
if (previousList != null) return previousList
|
||||
|
||||
cache[contactList] =
|
||||
user.latestContactList
|
||||
?.unverifiedFollowKeySet()
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
?.toSet()
|
||||
?.filter { !account.isHidden(it) }
|
||||
?.reversed()
|
||||
?: emptyList()
|
||||
|
||||
return cache[contactList] ?: emptyList()
|
||||
}
|
||||
}
|
||||
+53
-2
@@ -23,15 +23,66 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows.dal
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@Stable
|
||||
class UserProfileFollowsUserFeedViewModel(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
) : UserFeedViewModel(UserProfileFollowsFeedFilter(user, account)) {
|
||||
) : ViewModel() {
|
||||
val contactList = account.cache.getOrCreateAddressableNote(ContactListEvent.createAddress(user.pubkeyHex))
|
||||
|
||||
val sortingModel: Comparator<User> =
|
||||
compareBy(
|
||||
{ !account.isFollowing(it) },
|
||||
{ it.pubkeyHex },
|
||||
)
|
||||
|
||||
fun ContactListEvent.convertNonHiddenToUsers(): List<User> {
|
||||
val nonHiddenFollows = verifiedFollowKeySet().filter { !account.isHidden(it) }
|
||||
return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel)
|
||||
}
|
||||
|
||||
val followsFlow: StateFlow<List<User>> =
|
||||
contactList
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.sample(200)
|
||||
.map { noteState ->
|
||||
(noteState.note.event as? ContactListEvent)?.convertNonHiddenToUsers() ?: emptyList()
|
||||
}.onStart {
|
||||
emit(
|
||||
(contactList.event as? ContactListEvent)?.convertNonHiddenToUsers() ?: emptyList(),
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
initialValue = emptyList(),
|
||||
started = SharingStarted.Lazily,
|
||||
)
|
||||
|
||||
val followCount =
|
||||
followsFlow
|
||||
.map { it.size }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
initialValue = 0,
|
||||
started = SharingStarted.Lazily,
|
||||
)
|
||||
|
||||
class Factory(
|
||||
val user: User,
|
||||
val account: Account,
|
||||
|
||||
@@ -28,13 +28,10 @@ import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
|
||||
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
|
||||
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
@@ -58,8 +55,6 @@ class User(
|
||||
private var cards: UserCardsCache? = null
|
||||
private var nip05: UserNip05Cache? = null
|
||||
|
||||
var latestContactList: ContactListEvent? = null
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
@@ -128,20 +123,6 @@ class User(
|
||||
?.info
|
||||
?.lnAddress()
|
||||
|
||||
fun updateContactList(event: ContactListEvent): Set<HexKey> {
|
||||
if (event.id == latestContactList?.id) return emptySet()
|
||||
|
||||
val oldContactListEvent = latestContactList
|
||||
latestContactList = event
|
||||
|
||||
// Update following of the current user
|
||||
flowSet?.follows?.invalidateData()
|
||||
|
||||
val affectedUsers = event.verifiedFollowKeySet() + (oldContactListEvent?.verifiedFollowKeySet() ?: emptySet())
|
||||
|
||||
return affectedUsers
|
||||
}
|
||||
|
||||
fun addZap(
|
||||
zapRequest: Note,
|
||||
zap: Note?,
|
||||
@@ -214,10 +195,6 @@ class User(
|
||||
nip05StateOrNull()?.newMetadata(newUserInfo.nip05, metaEvent.pubKey)
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
|
||||
fun transientFollowCount(): Int? = latestContactList?.unverifiedFollowKeySet()?.size
|
||||
|
||||
fun reportsOrNull(): UserReportCache? = reports
|
||||
|
||||
fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it }
|
||||
@@ -322,15 +299,11 @@ class UserFlowSet(
|
||||
u: User,
|
||||
) {
|
||||
// Observers line up here.
|
||||
val follows = UserBundledRefresherFlow(u)
|
||||
val followers = UserBundledRefresherFlow(u)
|
||||
val usedRelays = UserBundledRefresherFlow(u)
|
||||
val zaps = UserBundledRefresherFlow(u)
|
||||
val statuses = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean =
|
||||
follows.hasObservers() ||
|
||||
followers.hasObservers() ||
|
||||
usedRelays.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
statuses.hasObservers()
|
||||
|
||||
-2
@@ -50,9 +50,7 @@ sealed interface Nip05State {
|
||||
fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted)
|
||||
|
||||
suspend fun checkAndUpdate(nip05Client: Nip05Client) {
|
||||
println("AABBCC checkAndUpdate $nip05")
|
||||
if (verificationState.value.isExpired()) {
|
||||
println("AABBCC Veryfing $nip05")
|
||||
markAsVerifying()
|
||||
try {
|
||||
if (nip05Client.verify(nip05, hexKey)) {
|
||||
|
||||
+14
-1
@@ -21,13 +21,16 @@
|
||||
package com.vitorpamplona.quartz.nip02FollowList
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.any
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
@@ -56,7 +59,11 @@ class ContactListEvent(
|
||||
|
||||
fun follows() = tags.mapNotNull(ContactTag::parseValid)
|
||||
|
||||
fun relays(): Map<NormalizedRelayUrl, ReadWrite>? {
|
||||
fun isFollowing(pubKey: HexKey) = tags.any(ContactTag::isTagged, pubKey)
|
||||
|
||||
fun followCount() = tags.count(ContactTag::isTagged)
|
||||
|
||||
fun relays(): Map<NormalizedRelayUrl, ReadWrite> {
|
||||
val regular = RelaySet.parse(content)
|
||||
|
||||
val normalized = mutableMapOf<NormalizedRelayUrl, ReadWrite>()
|
||||
@@ -75,6 +82,12 @@ class ContactListEvent(
|
||||
const val KIND = 3
|
||||
const val ALT = "Follow List"
|
||||
|
||||
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey)
|
||||
|
||||
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey)
|
||||
|
||||
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey)
|
||||
|
||||
fun blockListFor(pubKeyHex: HexKey): String = "3:$pubKeyHex:"
|
||||
|
||||
suspend fun createFromScratch(
|
||||
|
||||
+6
-1
@@ -54,7 +54,12 @@ data class ContactTag(
|
||||
companion object {
|
||||
const val TAG_NAME = "p"
|
||||
|
||||
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
fun isTagged(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
|
||||
|
||||
fun isTagged(
|
||||
tag: Array<String>,
|
||||
pubkey: HexKey,
|
||||
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pubkey
|
||||
|
||||
fun parse(tag: Array<String>): ContactTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
|
||||
Reference in New Issue
Block a user