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
@@ -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(),
@@ -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)
}
} }
} }
} }
@@ -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)
}
@@ -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() {
@@ -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
} }
@@ -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,
)
}
@@ -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
+2
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="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">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(),
)
}
}