mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-29 11:11:44 +01:00
Adds dynamic relay pool recommendations to the relay list.
This commit is contained in:
parent
347b16ee8b
commit
0564a7e1f1
@ -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
|
||||
|
@ -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(),
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxHeight()) { HomePages(pagerState, tabs, accountViewModel, nav) }
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
HomePages(pagerState, tabs, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@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="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>
|
||||
|
@ -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://")
|
||||
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user