diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt index ffe4c9cc5..3832f39b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/DebugUtils.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f35c140fa..00bcf4513 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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,77 +252,104 @@ object LocalCache : ILocalCache, ICacheProvider { val deletionIndex = DeletionIndex() - val observablesByKindAndETag = ConcurrentHashMap>>(10) - val observablesByKindAndAuthor = ConcurrentHashMap>>(10) + val observables = ConcurrentHashMap(10) - fun observeETag( - kind: Int, - eventId: HexKey, - scope: CoroutineScope, - ): LatestByKindWithETag { - var eTagList = observablesByKindAndETag.get(kind) - - if (eTagList == null) { - eTagList = ConcurrentHashMap>(1) as ConcurrentHashMap> - 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(kind, eventId) as LatestByKindWithETag - val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject - if (obj == newObject) { - // initialize - scope.launch(Dispatchers.IO) { - obj.init() + false + } + } + + fun filter(filter: Filter): SortedSet { + 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) + } + } + } + } else { + // optimized + byKinds.flatMap { kind -> + addressables.filter(kind) { _, note -> + filter.match(note) + } + } + } + } else { + addressables.filter { _, note -> + filter.match(note) } } - obj - } as LatestByKindWithETag - } - fun observeAuthor( - kind: Int, - pubkey: HexKey, - scope: CoroutineScope, - ): LatestByKindAndAuthor { - var authorObsList = observablesByKindAndAuthor.get(kind) - - if (authorObsList == null) { - authorObsList = ConcurrentHashMap>(1) as ConcurrentHashMap> - observablesByKindAndAuthor.put(kind, authorObsList) - } - - val value = authorObsList.get(pubkey) - - return if (value != null) { - value - } else { - val newObject = LatestByKindAndAuthor(kind, pubkey) as LatestByKindAndAuthor - val obj = authorObsList.putIfAbsent(pubkey, newObject) ?: newObject - if (obj == newObject) { - // initialize - scope.launch(Dispatchers.IO) { - obj.init() + val noteMatches = + notes.filter { _, note -> + val event = note.event + if (event != null && event.kind.isRegular()) { + filter.match(event) + } else { + false } } - obj - } as LatestByKindAndAuthor - } - private fun updateObservables(event: Event) { - observablesByKindAndETag[event.kind]?.let { observablesOfKind -> - event.forEachTaggedEventId { - observablesOfKind[it]?.updateIfMatches(event) + val limit = filter.limit + + val limitedSet = + if (limit != null) { + (addressableMatches + noteMatches).take(limit) + } else { + (addressableMatches + noteMatches) } - } - observablesByKindAndAuthor[event.kind]?.get(event.pubKey)?.updateIfMatches(event) + return limitedSet.toSortedSet(DefaultFeedOrder) } + fun observeNotes(filter: Filter): Flow> = + 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> = + 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 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() fun load(keys: List): List = keys.mapNotNull(::checkGetOrCreateUser) @@ -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) { - 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 { t, u -> + u.new(event, newNote) + } + + observables.forEach(observableBiConsumer) live.newNote(newNote) } private fun refreshDeletedNoteObservers(newNote: Note) { + val observableBiConsumer = + java.util.function.BiConsumer { t, u -> + u.remove(newNote) + } + + observables.forEach(observableBiConsumer) live.removedNote(newNote) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt index 46f8a0cba..a652bbc7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/Kind3FollowListState.kt @@ -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 = 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 = note.flow().metadata.stateFlow + + fun getFollowListEvent(): ContactListEvent? = note.event as? ContactListEvent @OptIn(ExperimentalCoroutinesApi::class) private val innerFlow: Flow = 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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/EventListMatchingFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/EventListMatchingFilter.kt new file mode 100644 index 000000000..4534d1dda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/EventListMatchingFilter.kt @@ -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) -> Unit, +) : Observable { + // Keeping this here blocks it from being cleared from memory + var currentResults: SortedSet = sortedSetOf(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 }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt deleted file mode 100644 index c8b43ab5d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindAndAuthor.kt +++ /dev/null @@ -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( - private val kind: Int, - private val pubkey: String, -) { - private val _latest = MutableStateFlow(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() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/NoteListMatchingFilter.kt similarity index 53% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/NoteListMatchingFilter.kt index 057b71030..cc4d17f7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/LatestByKindWithETag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/observables/NoteListMatchingFilter.kt @@ -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( - private val kind: Int, - private val eTag: String, -) { - private val _latest = MutableStateFlow(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) -> Unit, +) : Observable { + var currentResults: SortedSet = sortedSetOf(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()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 8e371a423..0e04ed278 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 2e0c03e88..033f24266 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -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 { - // 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 { - // 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 { - // 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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt index c4406d235..5bbd8807f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/DefaultFeedOrder.kt @@ -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 = compareByDescending { it.createdAt() }.thenBy { it.idHex } +val DefaultFeedOrderEvent: Comparator = + compareByDescending { it.createdAt }.thenBy { it.id } + val DefaultFeedOrderCard: Comparator = compareByDescending { it.createdAt() }.thenBy { it.id() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index a15f4c944..b465e19ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -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,18 +111,16 @@ fun NoteDropDownMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null, - ), - ) - } + val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + 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 = 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, + ) + }.onStart { + emit( DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note), - isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note), + 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 = showSensitiveContent, - ) - - launch(Dispatchers.Main) { - onNew( - newState, - ) - } - } + showSensitiveContent = accountViewModel.showSensitiveContent().value, + ), + ) + }.flowOn(Dispatchers.IO) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9d1feec1b..4b901a8c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 observeByETag( - kind: Int, - eTag: HexKey, - ): StateFlow = LocalCache.observeETag(kind = kind, eventId = eTag, viewModelScope).latest - - fun observeByAuthor( - kind: Int, - pubkeyHex: HexKey, - ): StateFlow = LocalCache.observeAuthor(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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 78cc44273..35a3cf771 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -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,18 +107,16 @@ fun BookmarkGroupItemOptionsMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } - var state by remember { - mutableStateOf( - DropDownParams( - isFollowingAuthor = false, - isPrivateBookmarkNote = false, - isPublicBookmarkNote = false, - isLoggedUser = false, - isSensitive = false, - showSensitiveContent = null, - ), - ) - } + val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + 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)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 80826edec..c7e0b354c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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.KIND, - dvmRequestId.idHex, - ) + accountViewModel.account.cache + .observeLatestEvent( + 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.KIND, dvmRequestId) + accountViewModel.account.cache + .observeLatestEvent( + 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index b8a5ba010..a224d95b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -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 && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 71eb5802c..a9cd5b3e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt index d242b9625..f7179c6b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/TabFollowers.kt @@ -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() } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt deleted file mode 100644 index 4d81c18ca..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersFeedFilter.kt +++ /dev/null @@ -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() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex - - override fun feed(): List = - LocalCache.users.filter { _, it -> - it.isFollowing(user) && !account.isHidden(it) - } - - override fun limit() = 400 -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt index 617a7c991..9e88f49cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt @@ -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 = + compareBy( + { !account.isFollowing(it) }, + { it.pubkeyHex }, + ) + + fun List.toNonHiddenOwners(): List = + mapNotNull { event -> + if (!account.isHidden(event.pubKey)) { + account.cache.getOrCreateUser(event.pubKey) + } else { + null + } + } + + val followersFlow: StateFlow> = + 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt index aaa112059..49d44b1ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/TabFollows.kt @@ -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() } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt deleted file mode 100644 index 0208a8a55..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsFeedFilter.kt +++ /dev/null @@ -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() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + user.pubkeyHex - - val cache: MutableMap> = mutableMapOf() - - override fun feed(): List { - 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() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt index 04e07c911..2911b62c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt @@ -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 = + compareBy( + { !account.isFollowing(it) }, + { it.pubkeyHex }, + ) + + fun ContactListEvent.convertNonHiddenToUsers(): List { + val nonHiddenFollows = verifiedFollowKeySet().filter { !account.isHidden(it) } + return LocalCache.load(nonHiddenFollows).sortedWith(sortingModel) + } + + val followsFlow: StateFlow> = + 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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index 82392c199..33b8733a4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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() private set @@ -128,20 +123,6 @@ class User( ?.info ?.lnAddress() - fun updateContactList(event: ContactListEvent): Set { - 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,16 +299,12 @@ 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() || + usedRelays.hasObservers() || zaps.hasObservers() || statuses.hasObservers() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt index 7cb2bcc27..e9bbb85e3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/Nip05State.kt @@ -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)) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index e25e3011a..6008900db 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -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? { + fun isFollowing(pubKey: HexKey) = tags.any(ContactTag::isTagged, pubKey) + + fun followCount() = tags.count(ContactTag::isTagged) + + fun relays(): Map { val regular = RelaySet.parse(content) val normalized = mutableMapOf() @@ -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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt index 107a96aa1..4c518b210 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/tags/ContactTag.kt @@ -54,7 +54,12 @@ data class ContactTag( companion object { const val TAG_NAME = "p" - fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun isTagged( + tag: Array, + pubkey: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == pubkey fun parse(tag: Array): ContactTag? { ensure(tag.has(1)) { return null }