Improvements to the relay settings page.

This commit is contained in:
Vitor Pamplona
2023-09-07 19:07:33 -04:00
parent b042110154
commit c2a4c9bbb3
11 changed files with 787 additions and 529 deletions

View File

@@ -14,4 +14,6 @@ data class RelaySetupInfo(
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
)
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
}

View File

@@ -0,0 +1,103 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
val retriever = Nip11Retriever()
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
onInfo(doc)
} else {
Nip11Retriever().loadRelayInfo(
url,
dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
onInfo(it)
},
onError
)
}
}
}
class Nip11Retriever {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS
}
fun cleanUrl(dirtyUrl: String): String {
return if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
}
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit
) {
try {
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_REACH_SERVER, e.message)
}
}
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
onError(dirtyUrl, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
@@ -50,24 +49,35 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
import com.vitorpamplona.amethyst.service.relays.FeedType
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.Font14SP
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.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
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.placeholderText
import kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.amethyst.ui.theme.warningColor
import kotlinx.coroutines.launch
import java.lang.Math.round
@@ -75,16 +85,9 @@ import java.lang.Math.round
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel.account)
postViewModel.relays.value.forEach { item ->
loadRelayInfo(item.url, context, scope) {
postViewModel.togglePaidRelay(item, it.limitation?.payment_required ?: false)
}
}
}
Dialog(
@@ -96,7 +99,9 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth().padding(end = 10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -108,11 +113,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
defaultRelays.forEach {
postViewModel.addRelay(it)
}
postViewModel.relays.value.forEach { item ->
loadRelayInfo(item.url, context, scope) {
postViewModel.togglePaidRelay(item, it.limitation?.payment_required ?: false)
}
}
postViewModel.loadRelayDocuments()
}
) {
Text(stringResource(R.string.default_relays))
@@ -139,8 +140,6 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
)
}
) { pad ->
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.padding(
16.dp,
@@ -158,10 +157,6 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
)
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
if (index == 0) {
ServerConfigHeader()
}
ServerConfig(
item,
onToggleDownload = { postViewModel.toggleDownload(it) },
@@ -175,9 +170,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
nav = nav,
scope = scope,
context = context
nav = nav
)
}
}
@@ -261,13 +254,38 @@ fun ServerConfigHeader() {
}
}
@OptIn(ExperimentalFoundationApi::class)
@Preview
@Composable
fun ServerConfigPreview() {
ServerConfigClickableLine(
item = RelaySetupInfo(
url = "nostr.mom",
read = true,
write = true,
errorCount = 23,
downloadCountInBytes = 10000,
uploadCountInBytes = 10000000,
spamCount = 10,
feedTypes = Constants.activeTypesGlobalChats,
paidRelay = true
),
onDelete = {},
onToggleDownload = {},
onToggleUpload = {},
onToggleFollows = {},
onTogglePrivateDMs = {},
onTogglePublicChats = {},
onToggleGlobal = {},
onToggleSearch = {},
onClick = {}
)
}
@Composable
fun ServerConfig(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
@@ -276,84 +294,297 @@ fun ServerConfig(
onDelete: (RelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
context: Context,
scope: CoroutineScope
nav: (String) -> Unit
) {
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
if (relayInfo != null) {
relayInfo?.let {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav
)
}
ServerConfigClickableLine(
item = item,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
onToggleFollows = onToggleFollows,
onTogglePrivateDMs = onTogglePrivateDMs,
onTogglePublicChats = onTogglePublicChats,
onToggleGlobal = onToggleGlobal,
onToggleSearch = onToggleSearch,
onDelete = onDelete,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = {
relayInfo = RelayInfoDialog(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)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
)
}
@Composable
fun ServerConfigClickableLine(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
onClick: () -> Unit
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
Column() {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
Column(Modifier.clickable(onClick = onClick)) {
RenderRelayIcon(iconUrl = item.briefInfo.favIcon, Size55dp)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth())
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
tint = Color.Red
RenderActiveToggles(
item = item,
onToggleFollows = onToggleFollows,
onTogglePrivateDMs = onTogglePrivateDMs,
onTogglePublicChats = onTogglePublicChats,
onToggleGlobal = onToggleGlobal,
onToggleSearch = onToggleSearch
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth()
) {
RenderStatusRow(
item = item,
onToggleDownload = onToggleDownload,
onToggleUpload = onToggleUpload,
modifier = HalfStartPadding.weight(1f)
)
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (item.paidRelay) {
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderStatusRow(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
modifier: Modifier
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Icon(
imageVector = Icons.Default.Paid,
null,
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.read_from_relay),
modifier = Modifier
.padding(end = 5.dp)
.size(14.dp),
tint = Color.Green
.size(15.dp)
.combinedClickable(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.read) {
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
}
)
Text(
text = item.url.removePrefix("wss://").removeSuffix("/"),
modifier = Modifier
.weight(1f)
.clickable {
loadRelayInfo(item.url, context, scope) {
relayInfo = it
}
},
text = countToHumanReadableBytes(item.downloadCountInBytes),
maxLines = 1,
overflow = TextOverflow.Ellipsis
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
)
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
modifier = Modifier
.size(15.dp)
.combinedClickable(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.write) {
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
}
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
)
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
modifier = Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.errorCount > 0) MaterialTheme.colors.warningColor else MaterialTheme.colors.allGoodColor
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
)
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
modifier = Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.spamCount > 0) MaterialTheme.colors.warningColor else MaterialTheme.colors.allGoodColor
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colors.placeholderText
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderActiveToggles(
item: RelaySetupInfo,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Text(
text = stringResource(id = R.string.active_for),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(start = 2.dp, end = 5.dp),
fontSize = 14.sp
)
IconButton(
modifier = Modifier
.size(30.dp),
onClick = { }
modifier = Size30Modifier,
onClick = { onToggleFollows(item) }
) {
Icon(
painterResource(R.drawable.ic_home),
stringResource(R.string.home_feed),
modifier = Modifier
.padding(end = 5.dp)
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleFollows(item) },
@@ -370,7 +601,7 @@ fun ServerConfig(
}
),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) {
Color.Green
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
@@ -379,8 +610,8 @@ fun ServerConfig(
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { }
modifier = Size30Modifier,
onClick = { onTogglePrivateDMs(item) }
) {
Icon(
painterResource(R.drawable.ic_dm),
@@ -403,7 +634,7 @@ fun ServerConfig(
}
),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) {
Color.Green
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
@@ -412,12 +643,12 @@ fun ServerConfig(
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { }
modifier = Size30Modifier,
onClick = { onTogglePublicChats(item) }
) {
Icon(
imageVector = Icons.Default.Groups,
stringResource(R.string.public_chat_feed),
contentDescription = stringResource(R.string.public_chat_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
@@ -436,7 +667,7 @@ fun ServerConfig(
}
),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) {
Color.Green
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
@@ -445,8 +676,8 @@ fun ServerConfig(
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { }
modifier = Size30Modifier,
onClick = { onToggleGlobal(item) }
) {
Icon(
imageVector = Icons.Default.Public,
@@ -469,7 +700,7 @@ fun ServerConfig(
}
),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) {
Color.Green
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
@@ -479,7 +710,7 @@ fun ServerConfig(
}
IconButton(
modifier = Modifier.size(30.dp),
modifier = Size30Modifier,
onClick = { onToggleSearch(item) }
) {
Icon(
@@ -503,7 +734,7 @@ fun ServerConfig(
}
),
tint = if (item.feedTypes.contains(FeedType.SEARCH)) {
Color.Green
MaterialTheme.colors.allGoodColor
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
@@ -512,164 +743,48 @@ fun ServerConfig(
)
}
}
}
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { }
@Composable
private fun FirstLine(
item: RelaySetupInfo,
onClick: () -> Unit,
onDelete: (RelaySetupInfo) -> Unit,
modifier: Modifier
) {
Icon(
imageVector = Icons.Default.Download,
stringResource(R.string.read_from_relay),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleDownload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.read_from_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.read) {
Color.Green
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${countToHumanReadable(item.downloadCountInBytes)}",
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
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.colors.allGoodColor
)
}
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { }
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { onToggleUpload(item) },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.write_to_relay),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.write) {
Color.Green
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
.padding(start = 10.dp)
.size(15.dp),
tint = WarningColor
)
}
)
}
Text(
text = "${countToHumanReadable(item.uploadCountInBytes)}",
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1.2f),
color = MaterialTheme.colors.placeholderText
)
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.errors),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
)
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT
)
.show()
}
}
),
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 12.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.placeholderText
)
}
}
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@@ -702,7 +817,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
modifier = Modifier
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else MaterialTheme.colors.placeholderText
tint = if (read) MaterialTheme.colors.allGoodColor else MaterialTheme.colors.placeholderText
)
}
@@ -713,7 +828,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
modifier = Modifier
.size(Size35dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else MaterialTheme.colors.placeholderText
tint = if (write) MaterialTheme.colors.allGoodColor else MaterialTheme.colors.placeholderText
)
}
@@ -739,9 +854,16 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
}
}
fun countToHumanReadable(counter: Int) = when {
counter >= 1000000000 -> "${round(counter / 1000000000f)}G"
counter >= 1000000 -> "${round(counter / 1000000f)}M"
counter >= 1000 -> "${round(counter / 1000f)}k"
fun countToHumanReadableBytes(counter: Int) = when {
counter >= 1000000000 -> "${round(counter / 1000000000f)} GB"
counter >= 1000000 -> "${round(counter / 1000000f)} MB"
counter >= 1000 -> "${round(counter / 1000f)} KB"
else -> "$counter"
}
fun countToHumanReadable(counter: Int, str: String) = when {
counter >= 1000000000 -> "${round(counter / 1000000000f)}G $str"
counter >= 1000000 -> "${round(counter / 1000000f)}M $str"
counter >= 1000 -> "${round(counter / 1000f)}K $str"
else -> "$counter $str"
}

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
@@ -24,6 +25,7 @@ class NewRelayListViewModel : ViewModel() {
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
@@ -36,6 +38,21 @@ class NewRelayListViewModel : ViewModel() {
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 {
var relayFile = account.userProfile().latestContactList?.relays()

View File

@@ -1,8 +1,5 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -28,27 +25,24 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfo,
relayInfo: RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -79,18 +73,25 @@ fun RelayInformationDialog(
})
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Title(relayInfo.name ?: "")
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = StdPadding.fillMaxWidth()) {
Column() {
RenderRelayIcon(
relayBriefInfo.favIcon,
Size55dp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
SectionContent(relayInfo.description ?: "")
Spacer(modifier = DoubleHorzSpacer)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row() {
Title(relayInfo.name?.trim() ?: "")
}
Row() {
SubtitleContent(relayInfo.description?.trim() ?: "")
}
}
}
Section(stringResource(R.string.owner))
@@ -212,7 +213,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
val text = item.toString().padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
@@ -222,7 +223,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
val text = item.padStart(2, '0')
Box(Modifier.padding(10.dp)) {
ClickableUrl(
urlText = "$text",
urlText = text,
url = "https://github.com/nostr-protocol/nips/blob/master/$text.md"
)
}
@@ -258,13 +259,18 @@ private fun DisplayOwnerInformation(
@Composable
fun Title(text: String) {
Spacer(modifier = DoubleVertSpacer)
Text(
text = text,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
)
Spacer(modifier = DoubleVertSpacer)
}
@Composable
fun SubtitleContent(text: String) {
Text(
text = text
)
}
@Composable
@@ -285,85 +291,3 @@ fun SectionContent(text: String) {
text = text
)
}
fun loadRelayInfo(
dirtyUrl: String,
context: Context,
scope: CoroutineScope,
onInfo: (RelayInformation) -> Unit
) {
try {
val url = if (dirtyUrl.contains("://")) {
dirtyUrl
.replace("wss://", "https://")
.replace("ws://", "http://")
} else {
"https://$dirtyUrl"
}
val request: Request = Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
HttpClient.getHttpClient()
.newCall(request)
.enqueue(
object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
Log.e("RelayInfoFail", "Resulting Message from Relay $dirtyUrl in not parseable: $body", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}
)
} catch (e: Exception) {
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
}
}

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -41,6 +42,11 @@ data class RelayList(
val isSelected: Boolean
)
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfo,
val relayInfo: RelayInformation
)
@Composable
fun RelaySelectionDialog(
preSelectedList: List<Relay>,
@@ -63,16 +69,17 @@ fun RelaySelectionDialog(
}
)
}
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
if (relayInfo != null) {
relayInfo?.let {
RelayInformationDialog(
onClose = {
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -161,9 +168,30 @@ fun RelaySelectionDialog(
}
},
onLongPress = {
loadRelayInfo(item.relay.url, context, scope) {
relayInfo = it
accountViewModel.retrieveRelayDocument(
item.relay.url,
onInfo = {
relayInfo = RelayInfoDialog(RelayBriefInfo(item.relay.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)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
)
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -28,14 +29,15 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.actions.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -112,8 +115,9 @@ fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav:
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
relayBriefInfo = relay,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -131,9 +135,30 @@ fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav:
interactionSource = interactionSource,
indication = ripple,
onClick = {
loadRelayInfo(relay.url, context, scope) {
accountViewModel.retrieveRelayDocument(
relay.url,
onInfo = {
relayInfo = 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)
}
scope.launch {
Toast
.makeText(
context,
msg,
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
)
}
@@ -146,12 +171,12 @@ fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav:
}
@Composable
private fun RenderRelayIcon(iconUrl: String) {
fun RenderRelayIcon(iconUrl: String, size: Dp = Size13dp) {
val backgroundColor = MaterialTheme.colors.background
val iconModifier = remember {
Modifier
.size(Size13dp)
.size(size)
.clip(shape = CircleShape)
.background(backgroundColor)
}

View File

@@ -19,10 +19,13 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
@@ -551,6 +554,16 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun retrieveRelayDocument(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError)
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel

View File

@@ -24,3 +24,9 @@ val DarkerGreen = Color.Green.copy(alpha = 0.32f)
val WarningColor = Color(0xFFC62828)
val RelayIconFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
val LightWarningColor = Color(0xFFffcc00)
val DarkWarningColor = Color(0xFFF8DE22)
val LightAllGoodColor = Color(0xFF339900)
val DarkAllGoodColor = Color(0xFF99cc33)

View File

@@ -286,6 +286,12 @@ val Colors.overPictureBackground: Color
val Colors.bitcoinColor: Color
get() = if (isLight) BitcoinLight else BitcoinDark
val Colors.warningColor: Color
get() = if (isLight) LightWarningColor else DarkWarningColor
val Colors.allGoodColor: Color
get() = if (isLight) LightAllGoodColor else DarkAllGoodColor
val Colors.markdownStyle: RichTextStyle
get() = if (isLight) MarkDownStyleOnLight else MarkDownStyleOnDark

View File

@@ -558,4 +558,16 @@
<string name="error_dialog_zap_error">Unable to send zap</string>
<string name="error_dialog_talk_to_user">Message the User</string>
<string name="error_dialog_button_ok">Ok</string>
<string name="relay_information_document_error_assemble_url">Failed to reach %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Failed to reach %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Failed to parse result from %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s failed with code %2$s</string>
<string name="active_for">Active for: </string>
<string name="active_for_home">Home</string>
<string name="active_for_msg">DMs</string>
<string name="active_for_chats">Chats</string>
<string name="active_for_global">Global</string>
<string name="active_for_search">Search</string>
</resources>