From 0564a7e1f1873ca9ec98bbf08dbcb8b986a7f3b1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Jul 2024 17:34:08 -0400 Subject: [PATCH] Adds dynamic relay pool recommendations to the relay list. --- .../vitorpamplona/amethyst/model/Account.kt | 64 ++++++- .../amethyst/service/NostrHomeDataSource.kt | 30 ++++ .../ui/actions/relays/AllRelayListView.kt | 12 ++ .../ui/actions/relays/BasicRelaySetupInfo.kt | 14 ++ .../ui/actions/relays/Kind3RelayListView.kt | 25 +++ .../actions/relays/Kind3RelayListViewModel.kt | 60 +++++++ .../Kind3RelaySetupInfoProposalDialog.kt | 116 +++++++++++++ .../relays/Kind3RelaySetupInfoProposalRow.kt | 136 +++++++++++++++ .../amethyst/ui/screen/loggedIn/HomeScreen.kt | 4 +- amethyst/src/main/res/values/strings.xml | 2 + .../quartz/encoders/RelayUrlFormatter.kt | 27 ++- .../quartz/utils/MinimumRelayListProcessor.kt | 164 ++++++++++++++++++ .../utils/MinimumRelayListProcessorTest.kt | 57 ++++++ 13 files changed, 701 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessor.kt create mode 100644 quartz/src/test/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 35553c316..07a0f0d6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 by lazy { + val liveHomeFollowListFlow: Flow by lazy { combinePeopleListFlows(liveKind3FollowsFlow, liveHomeList) - .stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + val liveHomeFollowLists: StateFlow by lazy { + liveHomeFollowListFlow.stateIn(scope, SharingStarted.Eagerly, LiveFollowLists()) + } + + fun relaysFromPeopleListFlows( + currentFollowList: LiveFollowLists, + relayUrlsToIgnore: Set, + ): Flow> = + 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> 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> 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 = getNIP65RelayListNote().flow().metadata.stateFlow + fun getNIP65RelayListFlow(pubkey: HexKey = signer.pubKey): StateFlow = 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) { if (!isWriteable()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index f9f159450..df1a06ca1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt index 415183e69..eccad5758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt @@ -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) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt index 2d03d4ad7..22dc3db91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfo.kt @@ -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, + val relayStat: RelayStat, + val paidRelay: Boolean = false, + val users: Set, +) { + val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt index 0a6b966ad..e2831107a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListView.kt @@ -143,6 +143,31 @@ fun LazyListScope.renderKind3Items( } } +fun LazyListScope.renderKind3ProposalItems( + feedState: List, + 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() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt index a0f66e2f3..2d2b26307 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt @@ -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>(emptyList()) val relays = _relays.asStateFlow() + private val _proposedRelays = MutableStateFlow>(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) { @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalDialog.kt new file mode 100644 index 000000000..06ed15434 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalDialog.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt new file mode 100644 index 000000000..048a82c52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelaySetupInfoProposalRow.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index a1017655e..23c757953 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c0072311c..d2a6612cd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -861,6 +861,8 @@ 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. General Relays Amethyst uses these relays to download posts for you. + Recommended Relays + Add the following relays to your General Relays list in order to receive posts from the listed users. Search Relays 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. Local Relays diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/RelayUrlFormatter.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/RelayUrlFormatter.kt index bbdd0f207..3b5b92e62 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/RelayUrlFormatter.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/RelayUrlFormatter.kt @@ -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://") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessor.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessor.kt new file mode 100644 index 000000000..f0ab1e3a1 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessor.kt @@ -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>, + ignore: Set = setOf(), + ): Map> { + val popularity = mutableMapOf>() + + 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, + hasOnionConnection: Boolean = false, + ): MutableMap> { + val validWriteRelayUrls = mutableMapOf>() + + 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, + relayUrlsToIgnore: Set = emptySet(), + hasOnionConnection: Boolean = false, + ): Set = + reliableRelaySetFor( + filterValidRelays(userList, hasOnionConnection), + relayUrlsToIgnore, + ) + + fun reliableRelaySetFor( + usersAndRelays: MutableMap>, + relayUrlsToIgnore: Set = emptySet(), + ): Set { + // 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() + + 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, + ) +} diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessorTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessorTest.kt new file mode 100644 index 000000000..bf5fab594 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/utils/MinimumRelayListProcessorTest.kt @@ -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(), + ) + } +}