Adds dynamic relay pool recommendations to the relay list.
This commit is contained in:
@@ -108,6 +108,7 @@ import com.vitorpamplona.quartz.signers.NostrSigner
|
|||||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
|
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -117,6 +118,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.combineTransform
|
import kotlinx.coroutines.flow.combineTransform
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
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)
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -2924,14 +2976,14 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getNIP65RelayListNote(): AddressableNote =
|
fun getNIP65RelayListNote(pubkey: HexKey = signer.pubKey): AddressableNote =
|
||||||
LocalCache.getOrCreateAddressableNote(
|
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>) {
|
fun sendNip65RelayList(relays: List<AdvertisedRelayListEvent.AdvertisedRelayInfo>) {
|
||||||
if (!isWriteable()) return
|
if (!isWriteable()) return
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
|||||||
import com.vitorpamplona.ammolite.relays.FeedType
|
import com.vitorpamplona.ammolite.relays.FeedType
|
||||||
import com.vitorpamplona.ammolite.relays.Filter
|
import com.vitorpamplona.ammolite.relays.Filter
|
||||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||||
|
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
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.LiveActivitiesChatMessageEvent
|
||||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||||
import com.vitorpamplona.quartz.events.PinListEvent
|
import com.vitorpamplona.quartz.events.PinListEvent
|
||||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||||
import com.vitorpamplona.quartz.events.RepostEvent
|
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? {
|
fun createFollowTagsFilter(): TypedFilter? {
|
||||||
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
|
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
|
||||||
|
|
||||||
@@ -235,6 +264,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
|||||||
followAccountChannel.typedFilters =
|
followAccountChannel.typedFilters =
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
createFollowAccountsFilter(),
|
createFollowAccountsFilter(),
|
||||||
|
createFollowMetadataAndReleaseFilter(),
|
||||||
createFollowCommunitiesFilter(),
|
createFollowCommunitiesFilter(),
|
||||||
createFollowTagsFilter(),
|
createFollowTagsFilter(),
|
||||||
createFollowGeohashesFilter(),
|
createFollowGeohashesFilter(),
|
||||||
|
|||||||
+12
@@ -38,6 +38,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -70,6 +71,7 @@ fun AllRelayListView(
|
|||||||
) {
|
) {
|
||||||
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
|
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
|
||||||
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
|
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
|
||||||
|
val kind3Proposals by kind3ViewModel.proposedRelays.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val dmViewModel: DMRelayListViewModel = viewModel()
|
val dmViewModel: DMRelayListViewModel = viewModel()
|
||||||
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
|
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
|
||||||
@@ -232,6 +234,16 @@ fun AllRelayListView(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
renderKind3Items(kind3FeedState, kind3ViewModel, accountViewModel, onClose, nav, relayToAdd)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable
|
|||||||
import com.vitorpamplona.ammolite.relays.FeedType
|
import com.vitorpamplona.ammolite.relays.FeedType
|
||||||
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
|
import com.vitorpamplona.ammolite.relays.RelayBriefInfoCache
|
||||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||||
|
import com.vitorpamplona.quartz.encoders.HexKey
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class BasicRelaySetupInfo(
|
data class BasicRelaySetupInfo(
|
||||||
@@ -45,3 +46,16 @@ data class Kind3BasicRelaySetupInfo(
|
|||||||
) {
|
) {
|
||||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+25
@@ -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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerConfigPreview() {
|
fun ServerConfigPreview() {
|
||||||
|
|||||||
+60
@@ -30,6 +30,7 @@ import com.vitorpamplona.ammolite.relays.FeedType
|
|||||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||||
import com.vitorpamplona.ammolite.relays.RelayStats
|
import com.vitorpamplona.ammolite.relays.RelayStats
|
||||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||||
|
import com.vitorpamplona.quartz.utils.MinimumRelayListProcessor
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -43,6 +44,9 @@ class Kind3RelayListViewModel : ViewModel() {
|
|||||||
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
|
private val _relays = MutableStateFlow<List<Kind3BasicRelaySetupInfo>>(emptyList())
|
||||||
val relays = _relays.asStateFlow()
|
val relays = _relays.asStateFlow()
|
||||||
|
|
||||||
|
private val _proposedRelays = MutableStateFlow<List<Kind3RelayProposalSetupInfo>>(emptyList())
|
||||||
|
val proposedRelays = _proposedRelays.asStateFlow()
|
||||||
|
|
||||||
var hasModified = false
|
var hasModified = false
|
||||||
|
|
||||||
fun load(account: Account) {
|
fun load(account: Account) {
|
||||||
@@ -127,6 +131,44 @@ class Kind3RelayListViewModel : ViewModel() {
|
|||||||
.reversed()
|
.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>) {
|
fun addAll(defaultRelays: Array<RelaySetupInfo>) {
|
||||||
@@ -153,16 +195,34 @@ class Kind3RelayListViewModel : ViewModel() {
|
|||||||
|
|
||||||
_relays.update { it.plus(relay) }
|
_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
|
hasModified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
|
fun deleteRelay(relay: Kind3BasicRelaySetupInfo) {
|
||||||
_relays.update { it.minus(relay) }
|
_relays.update { it.minus(relay) }
|
||||||
|
|
||||||
|
refreshProposals()
|
||||||
|
|
||||||
hasModified = true
|
hasModified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAll() {
|
fun deleteAll() {
|
||||||
_relays.update { relays -> emptyList() }
|
_relays.update { relays -> emptyList() }
|
||||||
|
|
||||||
|
refreshProposals()
|
||||||
|
|
||||||
hasModified = true
|
hasModified = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+136
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,9 @@ private fun AssembleHomePage(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit,
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) }
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
HomePages(pagerState, tabs, accountViewModel, nav)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -861,6 +861,8 @@
|
|||||||
<string name="private_outbox_section_explainer">Insert between 1–3 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="private_outbox_section_explainer">Insert between 1–3 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">General Relays</string>
|
||||||
<string name="kind_3_section_description">Amethyst uses these relays to download posts for you.</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">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="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>
|
<string name="local_section">Local Relays</string>
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ class RelayUrlFormatter {
|
|||||||
.removePrefix("ws://")
|
.removePrefix("ws://")
|
||||||
.removeSuffix("/")
|
.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 {
|
fun normalize(url: String): String {
|
||||||
val newUrl =
|
val newUrl =
|
||||||
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
|
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
|
||||||
// TODO: How to identify relays on the local network?
|
if (isOnion(url) || isLocalHost(url)) {
|
||||||
val isLocalHost = url.contains("127.0.0.1") || url.contains("localhost")
|
|
||||||
if (url.endsWith(".onion") || url.endsWith(".onion/") || isLocalHost) {
|
|
||||||
"ws://${url.trim()}"
|
"ws://${url.trim()}"
|
||||||
} else {
|
} else {
|
||||||
"wss://${url.trim()}"
|
"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 =
|
fun getHttpsUrl(dirtyUrl: String): String =
|
||||||
if (dirtyUrl.contains("://")) {
|
if (dirtyUrl.contains("://")) {
|
||||||
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
|
dirtyUrl.replace("wss://", "https://").replace("ws://", "http://")
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user