From 599fddb369e83c51b01ef40469b4681cf65cef21 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 22 May 2024 17:46:39 -0400 Subject: [PATCH] Adds a card to setup the DM relay list if the user doesn't have one. --- .../vitorpamplona/amethyst/model/Account.kt | 35 ++ .../amethyst/ui/actions/DMRelayListView.kt | 559 ++++++++++++++++++ .../ui/actions/DMRelayListViewModel.kt | 123 ++++ .../note/elements/AddInboxRelayForDMCard.kt | 149 ++--- .../ui/screen/loggedIn/ChatroomScreen.kt | 64 +- app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hu/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values/strings.xml | 12 +- .../quartz/events/AdvertisedRelayListEvent.kt | 5 + .../events/ChatMessageRelayListEvent.kt | 32 +- 11 files changed, 900 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListViewModel.kt 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 f4ec0dc85..9b0c50270 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageEvent +import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.Contact import com.vitorpamplona.quartz.events.ContactListEvent @@ -2545,6 +2546,40 @@ class Account( } } + fun getDMRelayList(): ChatMessageRelayListEvent? { + return LocalCache.getOrCreateAddressableNote( + ChatMessageRelayListEvent.createAddressATag(signer.pubKey), + ).event as? ChatMessageRelayListEvent + } + + fun saveDMRelayList(dmRelays: List) { + if (!isWriteable()) return + + val relayListForDMs = + LocalCache.getOrCreateAddressableNote( + ChatMessageRelayListEvent.createAddressATag(signer.pubKey), + ).event as? ChatMessageRelayListEvent + + if (relayListForDMs != null && relayListForDMs.tags.isNotEmpty()) { + ChatMessageRelayListEvent.updateRelayList( + earlierVersion = relayListForDMs, + relays = dmRelays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } else { + ChatMessageRelayListEvent.createFromScratch( + relays = dmRelays, + signer = signer, + ) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + } + fun setHideDeleteRequestDialog() { hideDeleteRequestDialog = true saveable.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListView.kt new file mode 100644 index 000000000..efe3b53f9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListView.kt @@ -0,0 +1,559 @@ +/** + * 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 + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Paid +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.RelayBriefInfoCache +import com.vitorpamplona.amethyst.service.Nip11CachedRetriever +import com.vitorpamplona.amethyst.service.Nip11Retriever +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.WarningColor +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.warningColor +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DMRelayListView( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + relayToAdd: String = "", + nav: (String) -> Unit, +) { + val postViewModel: DMRelayListViewModel = viewModel() + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { postViewModel.load(accountViewModel.account) } + + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Text(stringResource(R.string.dm_relays_title)) + + SaveButton( + onPost = { + postViewModel.create() + onClose() + }, + true, + ) + } + }, + navigationIcon = { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Column( + modifier = + Modifier.padding( + 16.dp, + pad.calculateTopPadding(), + 16.dp, + pad.calculateBottomPadding(), + ), + verticalArrangement = Arrangement.SpaceAround, + ) { + Card(modifier = MaterialTheme.colorScheme.imageModifier) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(id = R.string.dm_relays_not_found_editing), + ) + + Spacer(modifier = StdVertSpacer) + + Text( + text = stringResource(id = R.string.dm_relays_not_found_examples), + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + itemsIndexed(feedState, key = { _, item -> item.url }) { index, item -> + DMServerConfig( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + ) { + onClose() + nav(it) + } + } + } + } + + Spacer(modifier = StdVertSpacer) + + DMEditableServerConfig(relayToAdd) { postViewModel.addRelay(it) } + } + } + } +} + +@Composable +fun DMServerConfig( + item: DMRelayListViewModel.DMRelaySetupInfo, + onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> 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, + ) + } + + val automaticallyShowProfilePicture = + remember { + accountViewModel.settings.showProfilePictures.value + } + + DMServerConfigClickableLine( + item = item, + loadProfilePicture = automaticallyShowProfilePicture, + onDelete = onDelete, + 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 -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS -> + context.getString( + R.string.relay_information_document_error_assemble_url, + url, + exceptionMessage, + ) + } + + accountViewModel.toast( + context.getString(R.string.unable_to_download_relay_document), + msg, + ) + }, + ) + }, + ) +} + +@Composable +fun DMServerConfigClickableLine( + item: DMRelayListViewModel.DMRelaySetupInfo, + loadProfilePicture: Boolean, + onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit, + onClick: () -> Unit, + accountViewModel: AccountViewModel, +) { + 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, + MaterialTheme.colorScheme.largeRelayIconModifier, + ) + } + + Spacer(modifier = HalfHorzPadding) + + Column(Modifier.weight(1f)) { + FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth()) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = ReactionRowHeightChat.fillMaxWidth(), + ) { + RenderStatusRow( + item = item, + modifier = HalfStartPadding.weight(1f), + accountViewModel = accountViewModel, + ) + } + } + } + + HorizontalDivider(thickness = DividerThickness) + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun RenderStatusRow( + item: DMRelayListViewModel.DMRelaySetupInfo, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + Icon( + imageVector = Icons.Default.Download, + contentDescription = stringResource(R.string.read_from_relay), + modifier = + Modifier + .size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + accountViewModel.toast( + R.string.read_from_relay, + R.string.read_from_relay_description, + ) + }, + ), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + + Text( + text = countToHumanReadableBytes(item.downloadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) + + Icon( + imageVector = Icons.Default.Upload, + stringResource(R.string.write_to_relay), + modifier = + Modifier + .size(15.dp) + .combinedClickable( + onClick = { }, + onLongClick = { + accountViewModel.toast( + R.string.write_to_relay, + R.string.write_to_relay_description, + ) + }, + ), + tint = MaterialTheme.colorScheme.allGoodColor, + ) + + Text( + text = countToHumanReadableBytes(item.uploadCountInBytes), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) + + Icon( + imageVector = Icons.Default.SyncProblem, + stringResource(R.string.errors), + modifier = + Modifier + .size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + accountViewModel.toast( + R.string.errors, + R.string.errors_description, + ) + }, + ), + tint = + if (item.errorCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) + + Text( + text = countToHumanReadable(item.errorCount, "errors"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) + + Icon( + imageVector = Icons.Default.DeleteSweep, + stringResource(R.string.spam), + modifier = + Modifier + .size(15.dp) + .combinedClickable( + onClick = {}, + onLongClick = { + accountViewModel.toast( + R.string.spam, + R.string.spam_description, + ) + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.spam), + Toast.LENGTH_SHORT, + ) + .show() + } + }, + ), + tint = + if (item.spamCount > 0) { + MaterialTheme.colorScheme.warningColor + } else { + MaterialTheme.colorScheme.allGoodColor + }, + ) + + Text( + text = countToHumanReadable(item.spamCount, "spam"), + maxLines = 1, + fontSize = 12.sp, + modifier = modifier, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun FirstLine( + item: DMRelayListViewModel.DMRelaySetupInfo, + onClick: () -> Unit, + onDelete: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit, + modifier: Modifier, +) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + 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, + ) + } + } + + IconButton( + modifier = Modifier.size(30.dp), + onClick = { onDelete(item) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringResource(id = R.string.remove), + modifier = + Modifier + .padding(start = 10.dp) + .size(15.dp), + tint = WarningColor, + ) + } + } +} + +@Composable +fun DMEditableServerConfig( + relayToAdd: String, + onNewRelay: (DMRelayListViewModel.DMRelaySetupInfo) -> Unit, +) { + var url by remember { mutableStateOf(relayToAdd) } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { url = it }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + ) + + Button( + onClick = { + if (url.isNotBlank() && url != "/") { + var addedWSS = + if (!url.startsWith("wss://") && !url.startsWith("ws://")) { + if (url.endsWith(".onion") || url.endsWith(".onion/")) { + "ws://$url" + } else { + "wss://$url" + } + } else { + url + } + if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) + onNewRelay(DMRelayListViewModel.DMRelaySetupInfo(addedWSS)) + url = "" + } + }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringResource(id = R.string.add), color = Color.White) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListViewModel.kt new file mode 100644 index 000000000..c65e92065 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/DMRelayListViewModel.kt @@ -0,0 +1,123 @@ +/** + * 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 + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.RelayBriefInfoCache +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 DMRelayListViewModel : 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.saveDMRelayList(_relays.value.map { it.url }) + 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 -> }, + ) + } + } + } + + @Immutable + data class DMRelaySetupInfo( + val url: String, + val errorCount: Int = 0, + val downloadCountInBytes: Int = 0, + val uploadCountInBytes: Int = 0, + val spamCount: Int = 0, + val paidRelay: Boolean = false, + ) { + val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url) + } + + fun clear() { + _relays.update { + val relayList = account.getDMRelayList()?.relays() ?: 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 + + DMRelaySetupInfo( + relayUrl, + errorCounter, + eventDownloadCounter, + eventUploadCounter, + spamCounter, + ) + }.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed() + } + } + + fun addRelay(relay: DMRelaySetupInfo) { + if (relays.value.any { it.url == relay.url }) return + + _relays.update { it.plus(relay) } + } + + fun deleteRelay(relay: DMRelaySetupInfo) { + _relays.update { it.minus(relay) } + } + + fun deleteAll() { + _relays.update { relays -> emptyList() } + } + + fun togglePaidRelay( + relay: DMRelaySetupInfo, + paid: Boolean, + ) { + _relays.update { it.updated(relay, relay.copy(paidRelay = paid)) } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt index 74387850b..28ee3c25d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddInboxRelayForDMCard.kt @@ -20,52 +20,45 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -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.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ThemeType -import com.vitorpamplona.amethyst.ui.note.CloseIcon +import com.vitorpamplona.amethyst.ui.actions.DMRelayListView import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.KeyPair +import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent -import com.vitorpamplona.quartz.utils.TimeUtils import fr.acinq.secp256k1.Hex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking @Preview @Composable @@ -89,85 +82,93 @@ fun AddInboxRelayForDMCardPreview() { scope = myCoroutineScope, ) - runBlocking(Dispatchers.IO) { - val createAt = TimeUtils.now() - val list = listOf("wss://inbox.nostr.wine", "wss://vitor.nostr1.com") - val tags = ChatMessageRelayListEvent.createTagArray(list) - val id = "ab" - - LocalCache.justConsume( - ChatMessageRelayListEvent( - id = id, - pubKey = pubkey, - createdAt = createAt, - tags = tags, - content = "", - sig = "", - ), - null, - ) - } - val accountViewModel = AccountViewModel( myAccount, sharedPreferencesViewModel.sharedPrefs, ) + ThemeComparisonColumn { + AddInboxRelayForDMCard( + accountViewModel = accountViewModel, + nav = {}, + ) + } +} + +@Composable +fun ObserveRelayListForDMsAndDisplayIfNotFound( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + ObserveRelayListForDMs( + accountViewModel = accountViewModel, + ) { relayListEvent -> + if (relayListEvent == null) { + AddInboxRelayForDMCard( + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun ObserveRelayListForDMs( + accountViewModel: AccountViewModel, + inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, +) { + ObserveRelayListForDMs( + pubkey = accountViewModel.account.userProfile().pubkeyHex, + accountViewModel = accountViewModel, + ) { relayListEvent -> + inner(relayListEvent) + } +} + +@Composable +fun ObserveRelayListForDMs( + pubkey: HexKey, + accountViewModel: AccountViewModel, + inner: @Composable (relayListEvent: ChatMessageRelayListEvent?) -> Unit, +) { + println("AABBCC ObserveRelayListForDMs $pubkey") LoadAddressableNote( - ChatMessageRelayListEvent.createAddressTag("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"), + ChatMessageRelayListEvent.createAddressTag(pubkey), accountViewModel, ) { relayList -> - Text("Test" + relayList) if (relayList != null) { - ThemeComparisonColumn { - AddInboxRelayForDMCard( - relayList, - accountViewModel, - nav = {}, - ) - } + val relayListNoteState by relayList.live().metadata.observeAsState() + val relayListEvent = relayListNoteState?.note?.event as? ChatMessageRelayListEvent + + println("AABBCC ObserveRelayListForDMs Event $relayListEvent ${relayListNoteState?.note?.idHex}") + + inner(relayListEvent) } } } @Composable fun AddInboxRelayForDMCard( - baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val releaseNoteState by baseNote.live().metadata.observeAsState() - val releaseNote = releaseNoteState?.note ?: return - - Column(modifier = Modifier.padding(horizontal = Size10dp)) { + Column(modifier = StdPadding) { Card( modifier = MaterialTheme.colorScheme.imageModifier, ) { Column( - modifier = Modifier.padding(16.dp), + modifier = BigPadding, ) { // Title - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.dm_relays_not_found), - style = - TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - ), - ) - - IconButton( - modifier = Size20Modifier, - onClick = { accountViewModel.markDonatedInThisVersion() }, - ) { - CloseIcon() - } - } + Text( + text = stringResource(id = R.string.dm_relays_not_found), + style = + TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ), + ) Spacer(modifier = StdVertSpacer) @@ -177,8 +178,20 @@ fun AddInboxRelayForDMCard( Spacer(modifier = StdVertSpacer) + Text( + text = stringResource(id = R.string.dm_relays_not_found_examples), + ) + + Spacer(modifier = StdVertSpacer) + + var wantsToEditRelays by remember { mutableStateOf(false) } + if (wantsToEditRelays) { + DMRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav) + } + Button( onClick = { + wantsToEditRelays = true }, modifier = Modifier.fillMaxWidth(), ) { @@ -186,7 +199,5 @@ fun AddInboxRelayForDMCard( } } } - - Spacer(modifier = DoubleVertSpacer) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 49485b8f7..c30026003 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -104,6 +104,8 @@ import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs +import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -116,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.RelayUrlFormatter import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.findURLs @@ -295,8 +298,14 @@ fun ChatroomScreen( Column(Modifier.fillMaxHeight()) { val replyTo = remember { mutableStateOf(null) } + ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) + Column( - modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true), + modifier = + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true), ) { RefreshingChatroomFeedView( viewModel = feedViewModel, @@ -425,7 +434,10 @@ fun PrivateMessageEditFieldRow( UploadFromGallery( isUploading = channelScreenModel.isUploadingImage, tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.size(30.dp).padding(start = 2.dp), + modifier = + Modifier + .size(30.dp) + .padding(start = 2.dp), ) { channelScreenModel.upload( galleryUri = it, @@ -603,7 +615,8 @@ fun ChatroomHeader( ) { Column( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .clickable( onClick = onClick, ), @@ -635,7 +648,10 @@ fun GroupChatroomHeader( onClick: () -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), ) { Column( verticalArrangement = Arrangement.Center, @@ -669,7 +685,10 @@ private fun EditRoomSubjectButton( } Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { wantsToPost = true }, contentPadding = ZeroPadding, ) { @@ -703,7 +722,10 @@ fun NewSubjectView( val scope = rememberCoroutineScope() Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), + modifier = + Modifier + .padding(10.dp) + .verticalScroll(rememberScrollState()), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -755,7 +777,10 @@ fun NewSubjectView( OutlinedTextField( label = { Text(text = stringResource(R.string.messages_new_subject_message)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), value = message.value, onValueChange = { message.value = it }, placeholder = { @@ -824,6 +849,31 @@ fun LongRoomHeader( } } +@Composable +fun DMRelayLine( + baseUser: User, + accountViewModel: AccountViewModel, +) { + ObserveRelayListForDMs(pubkey = baseUser.pubkeyHex, accountViewModel = accountViewModel) { + val relayList = it?.relays() + if (relayList.isNullOrEmpty()) { + Text( + text = stringResource(id = R.string.dm_relays_regular), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Text( + text = stringResource(id = R.string.dm_relays_through, relayList.joinToString(", ") { RelayUrlFormatter.displayUrl(it) }), + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable fun RoomNameOnlyDisplay( room: ChatroomKey, diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d5b015646..a19ae66ff 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -672,7 +672,6 @@ Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s Configurer vos relais de messagerie privée Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages. - Les relais de messagerie DM acceptent n\'importe quel message de n\'importe qui, mais vous permet seulement de les télécharger. Par exemple, inbox.nostr.wine fonctionne de cette façon. Configurer maintenant Zap les Devs ! Votre don nous aide à faire la différence. Chaque sat compte ! diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 1ff01359b..c3507f4d4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -671,7 +671,6 @@ Az Amethyst kapott egy URI-t a megnyitáshoz, de az érvénytelen volt: %1$s Állítsd be a Privát postafiókod közvetítőit Ez a beállítás mindenkit tájékoztat, hogy melyik közvetítőt használod, amikor üzeneteket küldenek neked. Nélkülük néhány üzenetről lemaradhatsz. - A DM Inbox csomópontok bárkitől bármilyen üzenetet elfogadnak, de csak a letöltésüket teszik lehetővé. Például az inbox.nostr.wine így működik. Állítsd be most Zap a fejlesztőknek! Az adományod hozzájárul ahhoz, hogy változást érjünk el. Minden sat számít! diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2c30d1c7d..7e9cc2a5a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -672,7 +672,6 @@ Amethist ontving een URI om te openen, maar die uri was ongeldig: %1$s Stel uw Privé Postvak relays in Deze instelling geeft iedereen informatie over de relays die gebruikt worden bij het verzenden van berichten. Zonder hen mis je mogelijk een bericht. - DM postvak relays accepteren berichten van iedereen, maar kunnen ze alleen downloaden. Bijvoorbeeld, inbox.nostr.wine werkt op deze manier. Nu instellen Zap de devs! Jouw donatie helpt ons om een verschil te maken. Elke sat telt! diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 506647ce3..ee628becb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Group Picture Explicit Content Spam + The number of spamming events coming from this relay Impersonation Illegal Behavior Other @@ -96,6 +97,7 @@ Posts Bytes Errors + The number of connection errors in this session Home Feed Private Message Feed Public Chat Feed @@ -446,6 +448,8 @@ Zapraiser at %1$s. %2$s sats to goal Read from Relay Write to Relay + The amount in bytes that was sent to this relay, including filters and events + The amount in bytes that was received from this relay, including filters and events An error occurred trying to get relay information from %1$s Owner Version @@ -799,9 +803,13 @@ Invalid address Amethyst received a URI to open but that uri was invalid: %1$s + DM Inbox Relays + Relays: %1$s + Using Regular Relays Set up your Private Inbox relays - This setting informs everybody which relays to use when sending messages to you. Without them you might miss some messages. - DM Inbox relays accept any message from anyone, but only allows you to download them. For example, inbox.nostr.wine operates in this way. + This setting lets everybody know which relays to use when sending messages to you. Without them you might miss some messages. + Good options are:\n - inbox.nostr.wine (paid)\n - you.nostr1.com (personal relays - paid) + Insert between 1-3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them. Set up now Zap the Devs! diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt index a2b6e1bda..cac3b5d9f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/AdvertisedRelayListEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -57,6 +58,10 @@ class AdvertisedRelayListEvent( const val KIND = 10002 const val FIXED_D_TAG = "" + fun createAddressTag(pubKey: HexKey): ATag { + return ATag(KIND, pubKey, FIXED_D_TAG, null) + } + fun create( list: List, signer: NostrSigner, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt index 80fd277a5..b43188ae9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageRelayListEvent.kt @@ -51,6 +51,10 @@ class ChatMessageRelayListEvent( const val KIND = 10050 const val FIXED_D_TAG = "" + fun createAddressATag(pubKey: HexKey): ATag { + return ATag(KIND, pubKey, FIXED_D_TAG, null) + } + fun createAddressTag(pubKey: HexKey): String { return ATag.assembleATag(KIND, pubKey, FIXED_D_TAG) } @@ -58,7 +62,33 @@ class ChatMessageRelayListEvent( fun createTagArray(relays: List): Array> { return relays.map { arrayOf("relay", it) - }.plusElement(arrayOf("alt", "Relay list for private messages")).toTypedArray() + }.plusElement(arrayOf("alt", "Relay list to receive private messages")).toTypedArray() + } + + fun updateRelayList( + earlierVersion: ChatMessageRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChatMessageRelayListEvent) -> Unit, + ) { + val tags = + earlierVersion.tags.filter { it[0] != "relay" }.plus( + relays.map { + arrayOf("relay", it) + }, + ).toTypedArray() + + signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + } + + fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (ChatMessageRelayListEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) } fun create(