Merge branch 'main' into amber

This commit is contained in:
greenart7c3
2023-09-08 15:48:13 -03:00
committed by GitHub
40 changed files with 2318 additions and 759 deletions
@@ -2628,14 +2628,18 @@ class Account(
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
usersRelayList = usersRelayList + Relay(
Constants.forcedRelayForSearch.url,
Constants.forcedRelayForSearch.read,
Constants.forcedRelayForSearch.write,
Constants.forcedRelayForSearch.feedTypes,
proxy
)
val searchRelays = usersRelayList.filter { it.url.removeSuffix("/") in Constants.forcedRelaysForSearchSet }
val hasSearchRelay = usersRelayList.any { it.activeTypes.contains(FeedType.SEARCH) }
if (!hasSearchRelay && searchRelays.isEmpty()) {
usersRelayList = usersRelayList + Constants.forcedRelayForSearch.map {
Relay(
it.url,
it.read,
it.write,
it.feedTypes,
proxy
)
}
}
return usersRelayList.toTypedArray()
@@ -2653,6 +2657,10 @@ class Account(
.toTypedArray()
}
fun activeWriteRelays(): List<Relay> {
return (activeRelays() ?: convertLocalRelays()).filter { it.write }
}
fun reconnectIfRelaysHaveChanged() {
val newRelaySet = activeRelays() ?: convertLocalRelays()
if (!Client.isSameRelaySetConfig(newRelaySet)) {
@@ -734,6 +734,12 @@ class NoteLiveSet(u: Note) {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
val relayInfo = innerRelays.map {
it.note.relays.map {
RelayBriefInfo(it)
}.toImmutableList()
}
fun isInUse(): Boolean {
return metadata.hasObservers() ||
reactions.hasObservers() ||
@@ -812,3 +818,10 @@ class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveDat
@Immutable
class NoteState(val note: Note)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String = url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern()
)
@@ -14,4 +14,6 @@ data class RelaySetupInfo(
val spamCount: Int = 0,
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
)
) {
val briefInfo: RelayBriefInfo = RelayBriefInfo(url)
}
@@ -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)
}
}
}
@@ -14,6 +14,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
@@ -23,6 +24,7 @@ abstract class NostrDataSource(val debugName: String) {
data class Counter(var counter: Int)
private var eventCounter = mapOf<String, Counter>()
var changingFilters = AtomicBoolean()
fun printCounter() {
eventCounter.forEach {
@@ -59,11 +61,17 @@ abstract class NostrDataSource(val debugName: String) {
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
// updates a per subscripton since date
subscriptions[subscriptionId]?.updateEOSE(TimeUtils.now(), relay.url)
subscriptions[subscriptionId]?.updateEOSE(
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
relay.url
)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
if (success) {
markAsSeenOnRelay(eventId, relay)
}
}
override fun onAuth(relay: Relay, challenge: String) {
@@ -136,6 +144,8 @@ abstract class NostrDataSource(val debugName: String) {
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
changingFilters.getAndSet(true)
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
@@ -167,12 +177,18 @@ abstract class NostrDataSource(val debugName: String) {
}
}
}
changingFilters.getAndSet(false)
}
open fun consume(event: Event, relay: Relay) {
LocalCache.verifyAndConsume(event, relay)
}
open fun markAsSeenOnRelay(eventId: String, relay: Relay) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}
@@ -134,6 +134,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
// Ignores EOSE if it is in the middle of a filter change.
if (changingFilters.get()) return@requestNewChannel
checkNotInMainThread()
eventsToWatch.forEach {
@@ -16,9 +16,7 @@ object Constants {
}
val defaultRelays = arrayOf(
// Free relays for DMs and Follows
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
// Free relays for only DMs and Follows due to the amount of spam
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
// Chats
@@ -40,6 +38,7 @@ object Constants {
// NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
RelaySetupInfo("wss://relay.snort.social", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
@@ -51,8 +50,15 @@ object Constants {
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
val forcedRelayForSearch = arrayOf(
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://filter.nostr.wine", read = true, write = false, feedTypes = activeTypesSearch),
RelaySetupInfo("wss://relay.noswhere.com", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelaysForSearchSet = forcedRelayForSearch.map { it.url }
}
@@ -1,9 +1,13 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
@@ -12,6 +16,11 @@ object RelayPool : Relay.Listener {
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
// Backing property to avoid flow emissions from other classes
private var _lastStatus = RelayPoolStatus(0, 0)
private val _statusFlow = MutableSharedFlow<RelayPoolStatus>(1, 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val statusFlow: SharedFlow<RelayPoolStatus> = _statusFlow.asSharedFlow()
fun availableRelays(): Int {
return relays.size
}
@@ -76,11 +85,13 @@ object RelayPool : Relay.Listener {
fun addRelay(relay: Relay) {
relay.register(this)
relays += relay
updateStatus()
}
fun removeRelay(relay: Relay) {
relay.unregister(this)
relays = relays.minus(relay)
updateStatus()
}
fun register(listener: Listener) {
@@ -109,12 +120,14 @@ object RelayPool : Relay.Listener {
override fun onError(relay: Relay, subscriptionId: String, error: Error) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
refreshObservers()
updateStatus()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
refreshObservers()
if (type != Relay.Type.EOSE) {
updateStatus()
}
}
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
@@ -125,18 +138,15 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
private fun updateStatus() {
val connected = connectedRelays()
val available = availableRelays()
if (_lastStatus.connected != connected || _lastStatus.available != available) {
_lastStatus = RelayPoolStatus(connected, available)
_statusFlow.tryEmit(_lastStatus)
}
}
}
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
fun refresh() {
postValue(RelayPoolState(relays))
}
}
class RelayPoolState(val relays: RelayPool)
@Immutable
data class RelayPoolStatus(val connected: Int, val available: Int, val isConnected: Boolean = connected > 0)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
@@ -68,7 +69,10 @@ class MainActivity : AppCompatActivity() {
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity)
}
@@ -84,7 +88,8 @@ class MainActivity : AppCompatActivity() {
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
val connectivityManager =
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
}
@@ -162,7 +167,8 @@ class MainActivity : AppCompatActivity() {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasMobileData =
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
@@ -189,9 +195,15 @@ class MainActivity : AppCompatActivity() {
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
@SuppressLint("MissingSuperCall")
override fun createIntent(context: Context, input: String): Intent {
return super.createIntent(context, input).apply {
// Force only images and videos to be selectable
// Force OPEN Document because of the resulting URI must be passed to the
// Playback service and the picker's permissions only allow the activity to read the URI
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
// Force only images and videos to be selectable
type = "*/*"
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
@@ -79,13 +80,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
var relayList = account.activeWriteRelays()
Dialog(
onDismissRequest = { onClose() },
@@ -101,7 +96,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
preSelectedList = relayList,
onClose = {
showRelaysDialog = false
},
@@ -221,7 +216,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
try {
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
} catch (e: Exception) {
postViewModel.imageUploadingError.emit("Unable to load file")
postViewModel.imageUploadingError.emit("Unable to load thumbnail, but the video can be uploaded")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}
@@ -133,13 +133,7 @@ fun NewPostView(
var showRelaysDialog by remember {
mutableStateOf(false)
}
var relayList = account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: account.convertLocalRelays().filter {
it.write
}
var relayList = account.activeWriteRelays()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
@@ -169,7 +163,7 @@ fun NewPostView(
) {
if (showRelaysDialog) {
RelaySelectionDialog(
list = relayList,
preSelectedList = relayList,
onClose = {
showRelaysDialog = false
},
@@ -1376,8 +1370,8 @@ fun ImageVideoDescription(
try {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
onError("Unable to load file")
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
onError("Unable to load thumbnail")
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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,17 +38,34 @@ 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()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
relayFile = relayFile + Pair(
Constants.forcedRelayForSearch.url,
ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
)
if (relayFile?.none { it.key.removeSuffix("/") in Constants.forcedRelaysForSearchSet } == true) {
relayFile = relayFile + Constants.forcedRelayForSearch.map {
Pair(
it.url,
ContactListEvent.ReadWrite(it.read, it.write)
)
}
}
if (relayFile != null) {
@@ -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()
}
}
}
@@ -29,19 +29,27 @@ import androidx.compose.ui.unit.dp
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.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
data class RelayList(
val relay: Relay,
val relayInfo: RelayBriefInfo,
val isSelected: Boolean
)
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfo,
val relayInfo: RelayInformation
)
@Composable
fun RelaySelectionDialog(
list: List<Relay>,
preSelectedList: List<Relay>,
onClose: () -> Unit,
onPost: (list: List<Relay>) -> Unit,
accountViewModel: AccountViewModel,
@@ -49,34 +57,29 @@ fun RelaySelectionDialog(
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val relayList = accountViewModel.account.activeRelays()?.filter {
it.write
}?.map {
it
} ?: accountViewModel.account.convertLocalRelays().filter {
it.write
}
var relays by remember {
mutableStateOf(
relayList.map {
accountViewModel.account.activeWriteRelays().map {
RelayList(
it,
list.any { relay -> it.url == relay.url }
relay = it,
relayInfo = RelayBriefInfo(it.url),
isSelected = preSelectedList.any { relay -> it.url == relay.url }
)
}
)
}
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
)
}
@@ -115,14 +118,14 @@ fun RelaySelectionDialog(
}
)
PostButton(
SaveButton(
onPost = {
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
}
return@PostButton
return@SaveButton
}
onPost(selectedRelays.map { it.relay })
onClose()
@@ -153,10 +156,7 @@ fun RelaySelectionDialog(
key = { _, item -> item.relay.url }
) { index, item ->
RelaySwitch(
text = item.relay.url
.removePrefix("ws://")
.removePrefix("wss://")
.removeSuffix("/"),
text = item.relayInfo.displayUrl,
checked = item.isSelected,
onClick = {
relays = relays.mapIndexed { j, item ->
@@ -168,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()
}
}
)
}
)
}
@@ -451,8 +451,6 @@ fun GenericMainTopBar(
nav: (String) -> Unit,
content: @Composable (AccountViewModel) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = BottomTopHeight) {
MyTopAppBar(
elevation = 0.dp,
@@ -476,6 +474,7 @@ fun GenericMainTopBar(
}
},
navigationIcon = {
val coroutineScope = rememberCoroutineScope()
LoggedInUserPictureDrawer(accountViewModel) {
coroutineScope.launch {
scaffoldState.drawerState.open()
@@ -506,10 +505,9 @@ private fun LoggedInUserPictureDrawer(
accountViewModel: AccountViewModel,
onClick: () -> Unit
) {
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
val profilePicture by accountViewModel.account.userProfile().live().profilePictureChanges.observeAsState()
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture() }
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
IconButton(
onClick = onClick
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Send
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -63,7 +64,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
@@ -72,11 +72,12 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
@@ -454,7 +455,6 @@ fun ListContent(
}
val coroutineScope = rememberCoroutineScope()
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
var wantsToEditRelays by remember {
mutableStateOf(false)
}
@@ -490,7 +490,7 @@ fun ListContent(
)
IconRowRelays(
relayViewModel = relayViewModel,
accountViewModel = accountViewModel,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
@@ -633,27 +633,37 @@ private fun enableTor(
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel
) {
val connectedRelaysText by relayViewModel.connectionStatus.observeAsState("--/--")
val isConnected by relayViewModel.isConnected.observeAsState(false)
private fun RelayStatus(accountViewModel: AccountViewModel) {
val connectedRelaysText by RelayPool.statusFlow.collectAsState(initial = RelayPoolStatus(0, 0))
RenderRelayStatus(connectedRelaysText, isConnected)
RenderRelayStatus(connectedRelaysText)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean
relayPool: RelayPoolStatus
) {
val text by remember(relayPool) {
derivedStateOf {
"${relayPool.connected}/${relayPool.available}"
}
}
val placeHolder = MaterialTheme.colors.placeholderText
val color by remember(relayPool) {
derivedStateOf {
if (relayPool.isConnected) {
placeHolder
} else {
Color.Red
}
}
}
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.placeholderText
} else {
Color.Red
},
text = text,
color = color,
style = MaterialTheme.typography.subtitle1
)
}
@@ -709,7 +719,7 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
}
@Composable
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
fun IconRowRelays(accountViewModel: AccountViewModel, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -736,7 +746,7 @@ fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
Spacer(modifier = Modifier.width(Size16dp))
RelayStatus(relayViewModel = relayViewModel)
RelayStatus(accountViewModel = accountViewModel)
}
}
}
@@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.AmberUtils
import com.vitorpamplona.amethyst.service.OnlineChecker
@@ -1663,9 +1664,9 @@ fun DisplayRelaySet(
) {
val noteEvent = baseNote.event as? RelaySetEvent ?: return
val relays by remember {
mutableStateOf<ImmutableList<String>>(
noteEvent.relays().toImmutableList()
val relays by remember(baseNote) {
mutableStateOf(
noteEvent.relays().map { RelayBriefInfo(it) }.toImmutableList()
)
}
@@ -1720,7 +1721,7 @@ fun DisplayRelaySet(
toMembersShow.forEach { relay ->
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
Text(
relay.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
text = relay.displayUrl,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1730,7 +1731,7 @@ fun DisplayRelaySet(
)
Column(modifier = Modifier.padding(start = 10.dp)) {
RelayOptionsAction(relay, accountViewModel, nav)
RelayOptionsAction(relay.url, accountViewModel, nav)
}
}
}
@@ -2436,40 +2437,41 @@ private fun ReplyRow(
}
}
val showChannelInfo by remember(note) {
derivedStateOf {
if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()
} else {
null
}
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
showVideo = false,
showBottomDiviser = false,
sendToChannel = true,
modifier = MaterialTheme.colors.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav
)
}
if (showReply) {
val replyingDirectlyTo = remember { note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.kind } }
if (replyingDirectlyTo != null && unPackReply) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
} else {
// ReplyInformation(note.replyTo, noteEvent.mentions(), accountViewModel, nav)
}
} else {
val showChannelReply by remember {
derivedStateOf {
(noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) ||
(noteEvent is LiveActivitiesChatMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()))
} else if (showChannelInfo != null) {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember {
(note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList()
?: persistentListOf()
}
}
if (showChannelReply) {
val channelHex = note.channelHex()
channelHex?.let {
ChannelHeader(
channelHex = channelHex,
showVideo = false,
showBottomDiviser = false,
sendToChannel = true,
modifier = remember { Modifier.padding(vertical = 5.dp) },
accountViewModel = accountViewModel,
nav = nav
)
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { (note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf() }
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
}
}
@@ -11,17 +11,16 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
@@ -29,80 +28,40 @@ import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifie
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
public fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember {
val baseNumber = baseNote.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}.toImmutableList()
mutableStateOf(baseNumber)
}
var shortRelayList by remember {
mutableStateOf(lazyRelayList.take(3).toImmutableList())
}
val scope = rememberCoroutineScope()
WatchRelayLists(baseNote) { relayList ->
if (!equalImmutableLists(relayList, lazyRelayList)) {
scope.launch(Dispatchers.Main) {
lazyRelayList = relayList
shortRelayList = relayList.take(3).toImmutableList()
}
}
val nextShowMore = relayList.size > 3
if (nextShowMore != showShowMore) {
scope.launch(Dispatchers.Main) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
}
val relayList by baseNote.live().relayInfo.observeAsState(persistentListOf())
val shortRelayList by remember {
derivedStateOf {
relayList.take(3).toImmutableList()
}
}
Spacer(DoubleVertSpacer)
if (expanded) {
VerticalRelayPanelWithFlow(lazyRelayList, accountViewModel, nav)
VerticalRelayPanelWithFlow(relayList, accountViewModel, nav)
} else {
VerticalRelayPanelWithFlow(shortRelayList, accountViewModel, nav)
}
if (showShowMore && !expanded) {
if (relayList.size > 3 && !expanded) {
ShowMoreRelaysButton {
expanded = true
}
}
}
@Composable
private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String>) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
LaunchedEffect(key1 = noteRelaysState) {
launch(Dispatchers.IO) {
val relayList = noteRelaysState?.note?.relays?.map {
it.removePrefix("wss://").removePrefix("ws://")
} ?: emptyList()
onListChanges(relayList.toImmutableList())
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: ImmutableList<String>,
relays: ImmutableList<RelayBriefInfo>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -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
@@ -17,7 +18,6 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -29,13 +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
@@ -44,6 +46,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
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) {
@@ -59,15 +63,13 @@ public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewMo
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelays by baseNote.live().relays.map {
it.note.relays
}.observeAsState(baseNote.relays)
val noteRelays by baseNote.live().relayInfo.observeAsState()
FlowRow(StdStartPadding) {
val relaysToDisplay = remember(noteRelays, expanded.value) {
if (expanded.value) noteRelays else noteRelays.take(3)
if (expanded.value) noteRelays else noteRelays?.take(3)?.toImmutableList()
}
relaysToDisplay.forEach {
relaysToDisplay?.forEach {
RenderRelay(it, accountViewModel, nav)
}
}
@@ -104,14 +106,7 @@ fun ChatRelayExpandButton(onClick: () -> Unit) {
}
@Composable
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val iconUrl by remember(dirtyUrl) {
derivedStateOf {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
}
fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
if (relayInfo != null) {
@@ -120,8 +115,9 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
relayInfo = null
},
relayInfo = relayInfo!!,
accountViewModel,
nav
relayBriefInfo = relay,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -130,7 +126,7 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = Size15dp)
val clickableModifier = remember(dirtyUrl) {
val clickableModifier = remember(relay) {
Modifier
.padding(1.dp)
.size(Size15dp)
@@ -139,9 +135,30 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
interactionSource = interactionSource,
indication = ripple,
onClick = {
loadRelayInfo(dirtyUrl, context, scope) {
relayInfo = it
}
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()
}
}
)
}
)
}
@@ -149,17 +166,17 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
Box(
modifier = clickableModifier
) {
RenderRelayIcon(iconUrl)
RenderRelayIcon(relay.favIcon)
}
}
@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)
}
@@ -1,20 +0,0 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.relays.RelayPool
@Stable
class RelayPoolViewModel : ViewModel() {
val connectionStatus = RelayPool.live.map {
val connectedRelays = it.relays.connectedRelays()
val availableRelays = it.relays.availableRelays()
"$connectedRelays/$availableRelays"
}.distinctUntilChanged()
val isConnected = RelayPool.live.map {
it.relays.connectedRelays() > 0
}.distinctUntilChanged()
}
@@ -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
@@ -571,6 +574,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
@@ -416,14 +416,11 @@ private fun VideoUserOptionAction(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = remember(noteRelaysState) {
noteRelaysState?.note?.relays ?: emptySet()
}
val noteRelays by baseNote.live().relayInfo.observeAsState()
FlowRow() {
noteRelays.forEach { dirtyUrl ->
RenderRelay(dirtyUrl, accountViewModel, nav)
noteRelays?.forEach { relayInfo ->
RenderRelay(relayInfo, accountViewModel, nav)
}
}
}
@@ -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)
@@ -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