From 9f3d4db9ddd286219c86c3097588a0bfa6866443 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 May 2024 12:35:24 -0400 Subject: [PATCH] Adds Local Relays to the relay list screen, saving locally in the device. --- .../amethyst/LocalPreferences.kt | 4 + .../vitorpamplona/amethyst/model/Account.kt | 7 ++ .../ui/actions/relays/AllRelayListView.kt | 13 +++ .../ui/actions/relays/LocalRelayListView.kt | 77 +++++++++++++ .../actions/relays/LocalRelayListViewModel.kt | 109 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 6 files changed, 212 insertions(+) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 19a952ef7..730c807f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -77,6 +77,7 @@ private object PrefKeys { const val NOSTR_PUBKEY = "nostr_pubkey" const val RELAYS = "relays" const val DONT_TRANSLATE_FROM = "dontTranslateFrom" + const val LOCAL_RELAY_SERVERS = "localRelayServers" const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" @@ -284,6 +285,7 @@ object LocalPreferences { account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) } putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays)) putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom) + putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers) putString( PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences), @@ -410,6 +412,7 @@ object LocalPreferences { ?: setOf() val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf() + val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS @@ -577,6 +580,7 @@ object LocalPreferences { keyPair = keyPair, signer = signer, localRelays = localRelays, + localRelayServers = localRelayServers, dontTranslateFrom = dontTranslateFrom, languagePreferences = languagePreferences, translateTo = translateTo, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 86e0cb618..b796c0a17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -176,6 +176,7 @@ class Account( val keyPair: KeyPair, val signer: NostrSigner = NostrSignerInternal(keyPair), var localRelays: Set = Constants.defaultRelays.toSet(), + var localRelayServers: Set = setOf(), var dontTranslateFrom: Set = getLanguagesSpokenByUser(), var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, @@ -2443,6 +2444,12 @@ class Account( } } + fun updateLocalRelayServers(servers: Set) { + localRelayServers = servers + liveLanguages.invalidateData() + saveable.invalidateData() + } + fun addDontTranslateFrom(languageCode: String) { dontTranslateFrom = dontTranslateFrom.plus(languageCode) liveLanguages.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt index 37551192a..0af11273a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/AllRelayListView.kt @@ -76,11 +76,15 @@ fun AllRelayListView( val searchViewModel: SearchRelayListViewModel = viewModel() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() + val localViewModel: LocalRelayListViewModel = viewModel() + val localFeedState by localViewModel.relays.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { kind3ViewModel.load(accountViewModel.account) dmViewModel.load(accountViewModel.account) nip65ViewModel.load(accountViewModel.account) searchViewModel.load(accountViewModel.account) + localViewModel.load(accountViewModel.account) } Dialog( @@ -102,6 +106,7 @@ fun AllRelayListView( dmViewModel.create() nip65ViewModel.create() searchViewModel.create() + localViewModel.create() onClose() }, true, @@ -173,6 +178,14 @@ fun AllRelayListView( } renderSearchItems(searchFeedState, searchViewModel, accountViewModel, onClose, nav) + item { + SettingsCategory( + stringResource(R.string.local_section), + stringResource(R.string.local_section_explainer), + ) + } + renderLocalItems(localFeedState, localViewModel, accountViewModel, onClose, nav) + item { SettingsCategoryWithButton( stringResource(R.string.kind_3_section), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt new file mode 100644 index 000000000..1632360d2 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListView.kt @@ -0,0 +1,77 @@ +/** + * 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.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun LocalRelayList( + postViewModel: LocalRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: (String) -> Unit, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderLocalItems(feedState, postViewModel, accountViewModel, onClose, nav) + } + } +} + +fun LazyListScope.renderLocalItems( + feedState: List, + postViewModel: LocalRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: (String) -> Unit, +) { + itemsIndexed(feedState, key = { _, item -> "DM" + item.url }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + ) { + onClose() + nav(it) + } + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(it) } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt new file mode 100644 index 000000000..d26c7e3ee --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/LocalRelayListViewModel.kt @@ -0,0 +1,109 @@ +/** + * 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.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever +import com.vitorpamplona.amethyst.service.relays.RelayPool +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class LocalRelayListViewModel : ViewModel() { + private lateinit var account: Account + + private val _relays = MutableStateFlow>(emptyList()) + val relays = _relays.asStateFlow() + + fun load(account: Account) { + this.account = account + clear() + loadRelayDocuments() + } + + fun create() { + viewModelScope.launch(Dispatchers.IO) { + account.updateLocalRelayServers(_relays.value.map { it.url }.toSet()) + clear() + } + } + + fun loadRelayDocuments() { + viewModelScope.launch(Dispatchers.IO) { + _relays.value.forEach { item -> + Nip11CachedRetriever.loadRelayInfo( + dirtyUrl = item.url, + onInfo = { + togglePaidRelay(item, it.limitation?.payment_required ?: false) + }, + onError = { url, errorCode, exceptionMessage -> }, + ) + } + } + } + + fun clear() { + _relays.update { + val relayList = account.localRelayServers ?: emptyList() + + relayList.map { relayUrl -> + val liveRelay = RelayPool.getRelay(relayUrl) + val errorCounter = liveRelay?.errorCounter ?: 0 + val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0 + val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0 + val spamCounter = liveRelay?.spamCounter ?: 0 + + BasicRelaySetupInfo( + relayUrl, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + ) + }.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed() + } + } + + fun addRelay(relay: BasicRelaySetupInfo) { + if (relays.value.any { it.url == relay.url }) return + + _relays.update { it.plus(relay) } + } + + fun deleteRelay(relay: BasicRelaySetupInfo) { + _relays.update { it.minus(relay) } + } + + fun deleteAll() { + _relays.update { relays -> emptyList() } + } + + fun togglePaidRelay( + relay: BasicRelaySetupInfo, + paid: Boolean, + ) { + _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e09725ac..1989d8b01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -828,6 +828,8 @@ Amethyst uses these relays to download posts for you. Search Relays List of relays to use for search and tagging users. Tagging and search will not work if no options are available. + Local Relays + List of relays that are running in this device. Zap the Devs! Your donation helps us make a difference. Every sat counts!