Adds dynamic relay pool recommendations to the relay list.

This commit is contained in:
Vitor Pamplona 2024-07-15 17:34:08 -04:00
parent 347b16ee8b
commit 0564a7e1f1
13 changed files with 701 additions and 10 deletions

View File

@ -108,6 +108,7 @@ import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.signers.NostrSignerExternal
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@ -117,6 +118,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@ -474,9 +476,59 @@ class Account(
}
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
val liveHomeFollowListFlow: Flow<LiveFollowLists?> by lazy {
combinePeopleListFlows(liveKind3FollowsFlow, liveHomeList)
.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
val liveHomeFollowLists: StateFlow<LiveFollowLists?> by lazy {
liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists())
}
fun relaysFromPeopleListFlows(
currentFollowList: LiveFollowLists,
relayUrlsToIgnore: Set<String>,
): Flow<List<MinimumRelayListProcessor.RelayRecommendation>> =
combine(
currentFollowList.users.map {
getNIP65RelayListFlow(it)
},
) { followsNIP65RelayLists ->
MinimumRelayListProcessor
.reliableRelaySetFor(
followsNIP65RelayLists.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)
},
relayUrlsToIgnore,
hasOnionConnection = proxy != null,
).sortedByDescending { it.users.size }
}
@OptIn(ExperimentalCoroutinesApi::class)
val liveHomeFollowRelayFlow: Flow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
combineTransform(liveHomeFollowListFlow, connectToRelaysFlow) { followList, existing ->
if (followList != null) {
emit(
relaysFromPeopleListFlows(
followList,
existing.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
),
)
} else {
emit(MutableStateFlow(emptyList()))
}
}.flatMapLatest {
it
}
}
val liveHomeFollowRelays: StateFlow<List<MinimumRelayListProcessor.RelayRecommendation>> by lazy {
liveHomeFollowRelayFlow.stateIn(scope, SharingStarted.Eagerly, emptyList())
}
@OptIn(ExperimentalCoroutinesApi::class)
@ -2924,14 +2976,14 @@ class Account(
}
}
fun getNIP65RelayListNote(): AddressableNote =
fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote =
LocalCache.getOrCreateAddressableNote(
AdvertisedRelayListEvent.createAddressATag(signer.pubKey),
AdvertisedRelayListEvent.createAddressATag(pubkey),
)
fun getNIP65RelayListFlow(): StateFlow<NoteState> = getNIP65RelayListNote().flow().metadata.stateFlow
fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow<NoteState> = getNIP65RelayListNote(pubkey).flow().metadata.stateFlow
fun getNIP65RelayList(): AdvertisedRelayListEvent? = getNIP65RelayListNote().event as? AdvertisedRelayListEvent
fun getNIP65RelayList(pubkey: HexKey = signer.pubKey): AdvertisedRelayListEvent? = getNIP65RelayListNote(pubkey).event as? AdvertisedRelayListEvent
fun sendNip65RelayList(relays: List<AdvertisedRelayListEvent.AdvertisedRelayInfo>) {
if (!isWriteable()) return

View File

@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.Filter
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
@ -108,6 +110,33 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
)
}
fun createFollowMetadataAndReleaseFilter(): TypedFilter? {
val follows = account.liveHomeFollowLists.value?.users
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
return if (followSet != null) {
TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter =
Filter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
),
authors = followSet,
since =
latestEOSEs.users[account.userProfile()]
?.followList
?.get(account.defaultHomeFollowList.value)
?.relayList,
),
)
} else {
null
}
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
@ -235,6 +264,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
followAccountChannel.typedFilters =
listOfNotNull(
createFollowAccountsFilter(),
createFollowMetadataAndReleaseFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter(),

View File

@ -38,6 +38,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@ -70,6 +71,7 @@ fun AllRelayListView(
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle()
val dmViewModel: DMRelayListViewModel = viewModel()
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
@ -232,6 +234,16 @@ fun AllRelayListView(
)
}
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
if (kind3Proposals.isNotEmpty()) {
item {
SettingsCategory(
stringRes(R.string.kind_3_recommended_section),
stringRes(R.string.kind_3_recommended_section_description),
)
}
renderKind3ProposalItems(kind3Proposals, kind3ViewModel, accountViewModel, onClose, nav)
}
}
}
}

View File

@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
import com.vitorpamplona.ammolite.relays.RelayStat
import com.vitorpamplona.quartz.encoders.HexKey
@Immutable
data class BasicRelaySetupInfo(
@ -45,3 +46,16 @@ data class Kind3BasicRelaySetupInfo(
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
@Immutable
data class Kind3RelayProposalSetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val feedTypes: Set<FeedType>,
val relayStat: RelayStat,
val paidRelay: Boolean = false,
val users: Set<HexKey>,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}

View File

@ -143,6 +143,31 @@ fun LazyListScope.renderKind3Items(
}
}
fun LazyListScope.renderKind3ProposalItems(
feedState: List<Kind3RelayProposalSetupInfo>,
postViewModel: Kind3RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "kind3proposal" + item.url }) { index, item ->
Kind3RelaySetupInfoProposalDialog(
item = item,
onAdd = {
postViewModel.addRelay(item)
},
accountViewModel = accountViewModel,
nav = {
onClose()
nav(it)
},
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
@Preview
@Composable
fun ServerConfigPreview() {

View File

@ -30,6 +30,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
import com.vitorpamplona.ammolite.relays.RelayStats
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@ -43,6 +44,9 @@ class Kind3RelayListViewModel : ViewModel() {
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
private val _proposedRelays = MutableStateFlow<List<Kind3RelayProposalSetupInfo>>(emptyList())
val proposedRelays = _proposedRelays.asStateFlow()
var hasModified = false
fun load(account: Account) {
@ -127,6 +131,44 @@ class Kind3RelayListViewModel : ViewModel() {
.reversed()
}
}
refreshProposals()
}
private fun refreshProposals() {
_proposedRelays.update {
val proposed =
MinimumRelayListProcessor
.reliableRelaySetFor(
account.liveKind3Follows.value.users.mapNotNull {
account.getNIP65RelayList(it)
},
relayUrlsToIgnore =
_relays.value.mapNotNullTo(HashSet()) {
if (it.read && FeedType.FOLLOWS in it.feedTypes) {
it.url
} else {
null
}
},
hasOnionConnection = false,
).sortedByDescending { it.users.size }
proposed.mapNotNull {
if (it.requiredToNotMissEvents) {
Kind3RelayProposalSetupInfo(
url = RelayUrlFormatter.normalize(it.url),
read = true,
write = false,
feedTypes = setOf(FeedType.FOLLOWS),
relayStat = RelayStats.get(it.url),
users = it.users,
)
} else {
null
}
}
}
}
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
@ -153,16 +195,34 @@ class Kind3RelayListViewModel : ViewModel() {
_relays.update { it.plus(relay) }
refreshProposals()
hasModified = true
}
fun addRelay(relay: Kind3RelayProposalSetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update { it.plus(Kind3BasicRelaySetupInfo(relay.url, relay.read, relay.write, relay.feedTypes, relay.relayStat, relay.paidRelay)) }
refreshProposals()
hasModified = true
}
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
_relays.update { it.minus(relay) }
refreshProposals()
hasModified = true
}
fun deleteAll() {
_relays.update { relays -> emptyList() }
refreshProposals()
hasModified = true
}

View File

@ -0,0 +1,116 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
@Composable
fun Kind3RelaySetupInfoProposalDialog(
item: Kind3RelayProposalSetupInfo,
onAdd: (Kind3RelayProposalSetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current
relayInfo?.let {
RelayInformationDialog(
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav,
)
}
Kind3RelaySetupInfoProposalRow(
item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
onAdd = {
onAdd(item)
},
accountViewModel = accountViewModel,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it)
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
stringRes(
context,
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
stringRes(context, R.string.unable_to_download_relay_document),
msg,
)
},
)
},
nav = nav,
)
}

View File

@ -0,0 +1,136 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.relays
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Kind3RelaySetupInfoProposalRow(
item: Kind3RelayProposalSetupInfo,
loadProfilePicture: Boolean,
loadRobohash: Boolean,
onAdd: () -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
loadRobohash,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat.fillMaxWidth()) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
}
FlowRow(verticalArrangement = Arrangement.Center) {
item.users.forEach {
UserPicture(
userHex = it,
size = Size25dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
Column(
Modifier
.padding(start = 10.dp),
) {
AddRelayButton(onAdd)
}
}
HorizontalDivider(thickness = DividerThickness)
}
}

View File

@ -133,7 +133,9 @@ private fun AssembleHomePage(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) }
Column(Modifier.fillMaxHeight()) {
HomePages(pagerState, tabs, accountViewModel, nav)
}
}
@Composable

View File

@ -861,6 +861,8 @@
<string name="private_outbox_section_explainer">Insert between 13 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content.</string>
<string name="kind_3_section">General Relays</string>
<string name="kind_3_section_description">Amethyst uses these relays to download posts for you.</string>
<string name="kind_3_recommended_section">Recommended Relays</string>
<string name="kind_3_recommended_section_description">Add the following relays to your General Relays list in order to receive posts from the listed users.</string>
<string name="search_section">Search Relays</string>
<string name="search_section_explainer">List of relays to use when searching content or users. Tagging and search will not work if no options are available. Make sure they implement NIP-50.</string>
<string name="local_section">Local Relays</string>

View File

@ -31,12 +31,14 @@ class RelayUrlFormatter {
.removePrefix("ws://")
.removeSuffix("/")
fun isLocalHost(url: String) = url.contains("127.0.0.1") || url.contains("localhost")
fun isOnion(url: String) = url.endsWith(".onion") || url.endsWith(".onion/")
fun normalize(url: String): String {
val newUrl =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
// TODO: How to identify relays on the local network?
val isLocalHost = url.contains("127.0.0.1") || url.contains("localhost")
if (url.endsWith(".onion") || url.endsWith(".onion/") || isLocalHost) {
if (isOnion(url) || isLocalHost(url)) {
"ws://${url.trim()}"
} else {
"wss://${url.trim()}"
@ -52,6 +54,25 @@ class RelayUrlFormatter {
}
}
fun normalizeOrNull(url: String): String? {
val newUrl =
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
if (isOnion(url) || isLocalHost(url)) {
"ws://${url.trim()}"
} else {
"wss://${url.trim()}"
}
} else {
url.trim()
}
return try {
URIReference.parse(newUrl).normalize().toString()
} catch (e: Exception) {
null
}
}
fun getHttpsUrl(dirtyUrl: String): String =
if (dirtyUrl.contains("://")) {
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")

View File

@ -0,0 +1,164 @@
/**
* 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.quartz.utils
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
class MinimumRelayListProcessor {
companion object {
fun transpose(
userList: Map<HexKey, MutableSet<String>>,
ignore: Set<String> = setOf(),
): Map<String, MutableSet<HexKey>> {
val popularity = mutableMapOf<String, MutableSet<HexKey>>()
userList.forEach { event ->
event.value.forEach { relayUrl ->
if (relayUrl !in ignore) {
val set = popularity[relayUrl]
if (set != null) {
set.add(event.key)
} else {
popularity[relayUrl] = mutableSetOf(event.key)
}
}
}
}
return popularity
}
/**
* filter onion and local host from write relays
* for each user pubkey, a list of valid relays.
*/
fun filterValidRelays(
userList: List<AdvertisedRelayListEvent>,
hasOnionConnection: Boolean = false,
): MutableMap<HexKey, MutableSet<String>> {
val validWriteRelayUrls = mutableMapOf<HexKey, MutableSet<String>>()
userList.forEach { event ->
event.writeRelays().forEach { relayUrl ->
if (!RelayUrlFormatter.isLocalHost(relayUrl) && (hasOnionConnection || !RelayUrlFormatter.isOnion(relayUrl))) {
RelayUrlFormatter.normalizeOrNull(relayUrl)?.let { normRelayUrl ->
val set = validWriteRelayUrls[event.pubKey()]
if (set != null) {
set.add(normRelayUrl)
} else {
validWriteRelayUrls[event.pubKey()] = mutableSetOf(normRelayUrl)
}
}
}
}
}
return validWriteRelayUrls
}
fun reliableRelaySetFor(
userList: List<AdvertisedRelayListEvent>,
relayUrlsToIgnore: Set<String> = emptySet(),
hasOnionConnection: Boolean = false,
): Set<RelayRecommendation> =
reliableRelaySetFor(
filterValidRelays(userList, hasOnionConnection),
relayUrlsToIgnore,
)
fun reliableRelaySetFor(
usersAndRelays: MutableMap<HexKey, MutableSet<String>>,
relayUrlsToIgnore: Set<String> = emptySet(),
): Set<RelayRecommendation> {
// ignores users that are already being served by the list.
val usersToServeInTheFirstRound =
usersAndRelays
.filter {
it.value.none {
it in relayUrlsToIgnore
}
}.toMutableMap()
// returning this list.
val selected = relayUrlsToIgnore.toMutableSet()
val returningSet = mutableSetOf<RelayRecommendation>()
do {
// transposes the map so that we get a map of relays with users using them
val popularity = transpose(usersToServeInTheFirstRound, selected)
if (popularity.isEmpty()) {
// this happens when there are no relay options left for certain pubkeys
break
}
// chooses the most popular relay
val mostPopularRelay = popularity.maxBy { it.value.size }
selected.add(mostPopularRelay.key)
returningSet.add(RelayRecommendation(mostPopularRelay.key, true, mostPopularRelay.value))
// removes all users that we can now download posts from
mostPopularRelay.value.forEach {
usersToServeInTheFirstRound.remove(it)
}
} while (usersToServeInTheFirstRound.isNotEmpty())
// make a second pass to make sure each user is supported by at least 2 relays.
val usersServedByOnlyOneRelay =
usersAndRelays.filterTo(mutableMapOf()) { keyPair ->
keyPair.value.count {
it in selected
} < 2
}
do {
// transposes the map so that we get a map of relays with users using them
val popularity = transpose(usersServedByOnlyOneRelay, selected)
if (popularity.isEmpty()) {
// this happens when there are no relay options left for certain pubkeys
break
}
// chooses the most popular relay
val mostPopularRelay = popularity.maxBy { it.value.size }
selected.add(mostPopularRelay.key)
returningSet.add(RelayRecommendation(mostPopularRelay.key, false, mostPopularRelay.value))
// removes all users that we can now download posts from
mostPopularRelay.value.forEach {
usersServedByOnlyOneRelay.remove(it)
}
} while (usersServedByOnlyOneRelay.isNotEmpty())
return returningSet
}
}
class RelayRecommendation(
val url: String,
val requiredToNotMissEvents: Boolean,
val users: Set<HexKey>,
)
}

View File

@ -0,0 +1,57 @@
/**
* 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.quartz.utils
import junit.framework.TestCase.assertEquals
import org.junit.Test
class MinimumRelayListProcessorTest {
val userList =
mutableMapOf(
"User1" to mutableSetOf("wss://relay1.com", "wss://relay2.com", "wss://relay3.com"),
"User2" to mutableSetOf("wss://relay4.com", "wss://relay5.com", "wss://relay6.com"),
"User3" to mutableSetOf("wss://relay1.com", "wss://relay4.com", "wss://relay6.com"),
"User4" to mutableSetOf("wss://relay2.com", "wss://relay1.com", "wss://relay4.com"),
)
@Test
fun testTranspose() {
assertEquals(
mapOf(
"wss://relay1.com" to listOf("User1", "User3", "User4"),
"wss://relay2.com" to listOf("User1", "User4"),
"wss://relay3.com" to listOf("User1"),
"wss://relay4.com" to listOf("User2", "User3", "User4"),
"wss://relay5.com" to listOf("User2"),
"wss://relay6.com" to listOf("User2", "User3"),
).toString(),
MinimumRelayListProcessor.transpose(userList).toString(),
)
}
@Test
fun test1() {
assertEquals(
listOf("wss://relay1.com", "wss://relay4.com", "wss://relay2.com", "wss://relay5.com").toString(),
MinimumRelayListProcessor.reliableRelaySetFor(userList).toString(),
)
}
}