From a1aaec0b7d6cc14f5022462a54800e14a76ab396 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 7 Aug 2024 15:46:27 -0400 Subject: [PATCH] Adds support for selecting authors based on their Outbox relays when searching for notes authored by them --- .../vitorpamplona/amethyst/model/Account.kt | 89 +++++++++++++++++++ .../amethyst/service/NostrHomeDataSource.kt | 18 ++-- .../ammolite/relays/Subscription.kt | 36 ++++++++ .../filters/SinceAuthorPerRelayFilter.kt | 69 ++++++++++++++ 4 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 28610273f..8042abcb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -496,6 +496,95 @@ class Account( liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists(usersPlusMe = setOf(keyPair.pubKeyHex))) } + /** + * filter onion and local host from write relays + * for each user pubkey, a list of valid relays. + */ + private fun assembleAuthorsPerWriteRelay( + userList: Map>, + hasOnionConnection: Boolean = false, + ): Map> { + val authorsPerRelayUrl = mutableMapOf>() + val relayUrlsPerAuthor = mutableMapOf>() + + userList.forEach { userWriteRelayListPair -> + userWriteRelayListPair.value.forEach { relayUrl -> + if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) { + RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl -> + val userSet = authorsPerRelayUrl[normRelayUrl] + if (userSet != null) { + userSet.add(userWriteRelayListPair.key) + } else { + authorsPerRelayUrl[normRelayUrl] = mutableSetOf(userWriteRelayListPair.key) + } + + val relaySet = authorsPerRelayUrl[userWriteRelayListPair.key] + if (relaySet != null) { + relaySet.add(normRelayUrl) + } else { + relayUrlsPerAuthor[userWriteRelayListPair.key] = mutableSetOf(normRelayUrl) + } + } + } + } + } + + // for each relay, authors that only use this relay go first. + // then keeps order by pubkey asc + val comparator = compareByDescending { relayUrlsPerAuthor[it]?.size ?: 0 }.thenBy { it } + + return authorsPerRelayUrl.mapValues { + it.value.sortedWith(comparator) + } + } + + fun authorsPerRelay( + pubkeyList: Set, + defaultRelayList: List, + ): Flow>> = + combine( + pubkeyList.map { + getNIP65RelayListFlow(it) + }, + ) { followsNIP65RelayLists -> + assembleAuthorsPerWriteRelay( + followsNIP65RelayLists + .mapNotNull { + val author = (it.note as? AddressableNote)?.address?.pubKeyHex + val event = (it.note.event as? AdvertisedRelayListEvent) + + if (event != null) { + event.pubKey to event.writeRelays() + } else { + if (author != null) { + author to defaultRelayList + } else { + Log.e("Account", "This author should NEVER be null. Note: ${it.note.idHex}") + null + } + } + }.toMap(), + hasOnionConnection = proxy != null, + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveHomeListAuthorsPerRelayFlow: Flow>?> by lazy { + combineTransform(liveHomeFollowListFlow, connectToRelaysFlow) { followList, existing -> + if (followList != null) { + emit(authorsPerRelay(followList.usersPlusMe, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url })) + } else { + emit(MutableStateFlow(null)) + } + }.flatMapLatest { + it + } + } + + val liveHomeListAuthorsPerRelay: StateFlow>?> by lazy { + liveHomeListAuthorsPerRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + } + fun relaysFromPeopleListFlows( currentFollowList: LiveFollowLists, relayUrlsToIgnore: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 88fe876ae..fc2f3db19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.ammolite.relays.FeedType import com.vitorpamplona.ammolite.relays.TypedFilter +import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent @@ -76,13 +77,13 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { } fun createFollowAccountsFilter(): TypedFilter { - val follows = account.liveHomeFollowLists.value?.users - val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null } + val follows = + account.liveHomeListAuthorsPerRelay.value return TypedFilter( types = setOf(if (follows == null) FeedType.GLOBAL else FeedType.FOLLOWS), filter = - SincePerRelayFilter( + SinceAuthorPerRelayFilter( kinds = listOf( TextNoteEvent.KIND, @@ -99,7 +100,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { LiveActivitiesEvent.KIND, WikiNoteEvent.KIND, ), - authors = followSet, + authors = follows, limit = 400, since = latestEOSEs.users[account.userProfile()] @@ -111,20 +112,19 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") { } fun createFollowMetadataAndReleaseFilter(): TypedFilter? { - val follows = account.liveHomeFollowLists.value?.users - val followSet = follows?.plus(account.userProfile().pubkeyHex)?.shuffled()?.ifEmpty { null } + val follows = account.liveHomeListAuthorsPerRelay.value - return if (followSet != null) { + return if (follows != null) { TypedFilter( types = setOf(FeedType.FOLLOWS), filter = - SincePerRelayFilter( + SinceAuthorPerRelayFilter( kinds = listOf( MetadataEvent.KIND, AdvertisedRelayListEvent.KIND, ), - authors = followSet.take(500), + authors = follows, since = latestEOSEs.users[account.userProfile()] ?.followList diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt index 80cdc8951..9a4f8facd 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Subscription.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.ammolite.relays +import com.vitorpamplona.ammolite.relays.filters.SinceAuthorPerRelayFilter import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter import java.util.UUID @@ -47,6 +48,10 @@ data class Subscription( return isDifferent(typedFilter.filter, otherFilter.filter) } + if (typedFilter.filter is SinceAuthorPerRelayFilter && otherFilter.filter is SinceAuthorPerRelayFilter) { + return isDifferent(typedFilter.filter, otherFilter.filter) + } + return true } return false @@ -78,6 +83,37 @@ data class Subscription( ) { return true } + + return false + } + + fun isDifferent( + filter1: SinceAuthorPerRelayFilter, + filter2: SinceAuthorPerRelayFilter, + ): Boolean { + // Does not check SINCE on purpose. Avoids replacing the filter if SINCE was all that changed. + // fast check + if (filter1.authors?.size != filter2.authors?.size || + filter1.ids?.size != filter2.ids?.size || + filter1.tags?.size != filter2.tags?.size || + filter1.kinds?.size != filter2.kinds?.size || + filter1.limit != filter2.limit || + filter1.search?.length != filter2.search?.length || + filter1.until != filter2.until + ) { + return true + } + + // deep check + if (filter1.ids != filter2.ids || + filter1.authors != filter2.authors || + filter1.tags != filter2.tags || + filter1.kinds != filter2.kinds || + filter1.search != filter2.search + ) { + return true + } + return false } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt new file mode 100644 index 000000000..e2ec61eb2 --- /dev/null +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/filters/SinceAuthorPerRelayFilter.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.ammolite.relays.filters + +import com.vitorpamplona.quartz.events.Event + +/** + * This is a nostr filter with per-relay authors list and since parameters + */ +class SinceAuthorPerRelayFilter( + val ids: List? = null, + val authors: Map>? = null, + val kinds: List? = null, + val tags: Map>? = null, + val since: Map? = null, + val until: Long? = null, + val limit: Int? = null, + val search: String? = null, +) : IPerRelayFilter { + override fun toJson(forRelay: String) = FilterSerializer.toJson(ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until, limit, search) + + override fun match( + event: Event, + forRelay: String, + ) = FilterMatcher.match(event, ids, authors?.get(forRelay), kinds, tags, since?.get(forRelay)?.time, until) + + override fun toDebugJson(): String { + val factory = Event.mapper.nodeFactory + val obj = FilterSerializer.toJsonObject(ids, null, kinds, tags, null, until, limit, search) + authors?.run { + if (isNotEmpty()) { + val jsonObjectPerRelayAuthors = factory.objectNode() + entries.forEach { relayAuthorPairs -> + jsonObjectPerRelayAuthors.put(relayAuthorPairs.key, factory.arrayNode(relayAuthorPairs.value.size).apply { relayAuthorPairs.value.forEach { add(it) } }) + } + obj.put("authors", jsonObjectPerRelayAuthors) + } + } + + since?.run { + if (isNotEmpty()) { + val jsonObjectSince = factory.objectNode() + entries.forEach { sincePairs -> + jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") + } + obj.put("since", jsonObjectSince) + } + } + return Event.mapper.writeValueAsString(obj) + } +}