Relay Management (View/Edit)

This commit is contained in:
Vitor Pamplona
2023-01-23 13:58:06 -03:00
parent c33f7f615f
commit a47aaab83c
15 changed files with 814 additions and 113 deletions

View File

@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
this.account = account
start()
}
fun start() {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect()
}
}
fun pause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
}
}

View File

@ -8,6 +8,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import java.util.Date import java.util.Date
import nostr.postr.Contact import nostr.postr.Contact
@ -40,6 +42,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return loggedIn.privKey != null return loggedIn.privKey != null
} }
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows,
relays,
loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event)
LocalCache.consume(event)
}
fun sendNewUserMetadata(toString: String) { fun sendNewUserMetadata(toString: String) {
if (!isWriteable()) return if (!isWriteable()) return
@ -234,6 +253,21 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
} }
} }
fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
println("Updating Relays AAA Account")
Client.disconnect()
Client.connect(activeRelays() ?: Constants.defaultRelays)
RelayPool.requestAndWatch()
}
})
}
// Observers line up here. // Observers line up here.
val live: AccountLiveData = AccountLiveData(this) val live: AccountLiveData = AccountLiveData(this)

View File

@ -4,6 +4,7 @@ import android.util.Log
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@ -19,6 +20,7 @@ import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent import nostr.postr.events.RecommendRelayEvent
@ -110,10 +112,10 @@ object LocalCache {
// Adds notifications to users. // Adds notifications to users.
mentions.forEach { mentions.forEach {
it.taggedPosts.add(note) it.addTaggedPost(note)
} }
replyTo.forEach { replyTo.forEach {
it.author?.taggedPosts?.add(note) it.author?.addTaggedPost(note)
} }
// Counts the replies // Counts the replies
@ -148,6 +150,26 @@ object LocalCache {
event.createdAt event.createdAt
) )
if (user.pubkeyHex == "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
println("Updating Relays AAA ${user.toBestDisplayName()} ${event.content} ${formattedDateTime(event.createdAt)}")
try {
if (event.content.isNotEmpty()) {
if (user.pubkeyHex == "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
println("Updating Relays AAA 1 ${user.toBestDisplayName()} ${event.content}")
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
if (user.pubkeyHex == "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")
println("Updating Relays AAA 2 for ${user.bestUsername()} ${e.message}")
e.printStackTrace()
}
user.latestContactList = event user.latestContactList = event
} }
@ -201,10 +223,10 @@ object LocalCache {
// Adds notifications to users. // Adds notifications to users.
mentions.forEach { mentions.forEach {
it.taggedPosts.add(note) it.addTaggedPost(note)
} }
repliesTo.forEach { repliesTo.forEach {
it.author?.taggedPosts?.add(note) it.author?.addTaggedPost(note)
} }
// Counts the replies // Counts the replies
@ -231,10 +253,10 @@ object LocalCache {
// Adds notifications to users. // Adds notifications to users.
mentions.forEach { mentions.forEach {
it.taggedPosts.add(note) it.addTaggedPost(note)
} }
repliesTo.forEach { repliesTo.forEach {
it.author?.taggedPosts?.add(note) it.author?.addTaggedPost(note)
} }
if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") { if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") {
@ -312,10 +334,10 @@ object LocalCache {
// Adds notifications to users. // Adds notifications to users.
mentions.forEach { mentions.forEach {
it.taggedPosts.add(note) it.addTaggedPost(note)
} }
replyTo.forEach { replyTo.forEach {
it.author?.taggedPosts?.add(note) it.author?.addTaggedPost(note)
} }
// Counts the replies // Counts the replies

View File

@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections import java.util.Collections
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -11,6 +13,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent import nostr.postr.events.MetadataEvent
class User(val pubkey: ByteArray) { class User(val pubkey: ByteArray) {
@ -27,8 +30,11 @@ class User(val pubkey: ByteArray) {
val notes = Collections.synchronizedSet(mutableSetOf<Note>()) val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>()) val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>()) val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var relays: Map<String, ContactListEvent.ReadWrite>? = null
val followers = Collections.synchronizedSet(mutableSetOf<User>()) val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>() val messages = ConcurrentHashMap<User, MutableSet<Note>>()
@ -55,6 +61,10 @@ class User(val pubkey: ByteArray) {
invalidateData() invalidateData()
user.invalidateData() user.invalidateData()
listeners.forEach {
it.onFollowsChange()
}
} }
fun unfollow(user: User) { fun unfollow(user: User) {
follows.remove(user) follows.remove(user)
@ -62,6 +72,15 @@ class User(val pubkey: ByteArray) {
invalidateData() invalidateData()
user.invalidateData() user.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun addTaggedPost(note: Note) {
taggedPosts.add(note)
updateSubscribers { it.onNewPosts() }
} }
@Synchronized @Synchronized
@ -76,6 +95,7 @@ class User(val pubkey: ByteArray) {
fun addMessage(user: User, msg: Note) { fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg) getOrCreateChannel(user).add(msg)
live.refresh() live.refresh()
updateSubscribers { it.onNewMessage() }
} }
fun updateFollows(newFollows: Set<User>, updateAt: Long) { fun updateFollows(newFollows: Set<User>, updateAt: Long) {
@ -95,6 +115,15 @@ class User(val pubkey: ByteArray) {
updatedFollowsAt = updateAt updatedFollowsAt = updateAt
} }
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
listeners.forEach {
it.onRelayChange()
}
}
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) { fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo info = newUserInfo
updatedMetadataAt = updateAt updatedMetadataAt = updateAt
@ -108,7 +137,42 @@ class User(val pubkey: ByteArray) {
} }
} }
// Observers line up here. // Model Observers
private var listeners = setOf<Listener>()
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
abstract class Listener {
open fun onRelayChange() = Unit
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewMessage() = Unit
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
modelHandlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
}
}
// UI Observers line up here.
val live: UserLiveData = UserLiveData(this) val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches. // Refreshes observers in batches.

View File

@ -13,27 +13,11 @@ import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") { object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
lateinit var account: Account lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
resetFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener)
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener)
}
fun createAccountContactListFilter(): JsonFilter { fun createAccountContactListFilter(): JsonFilter {
return JsonFilter( return JsonFilter(
kinds = listOf(ContactListEvent.kind), kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 1 limit = 5
) )
} }
@ -41,7 +25,7 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
return JsonFilter( return JsonFilter(
kinds = listOf(MetadataEvent.kind), kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex), authors = listOf(account.userProfile().pubkeyHex),
limit = 3 limit = 5
) )
} }

View File

@ -3,8 +3,12 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex import nostr.postr.toHex
@ -12,20 +16,22 @@ import nostr.postr.toHex
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") { object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
lateinit var account: Account lateinit var account: Account
private val cacheListener: (UserState) -> Unit = { object cacheListener: User.Listener() {
resetFilters() override fun onFollowsChange() {
resetFilters()
}
} }
override fun start() { override fun start() {
if (this::account.isInitialized) if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener) account.userProfile().subscribe(cacheListener)
super.start() super.start()
} }
override fun stop() { override fun stop() {
super.stop() super.stop()
if (this::account.isInitialized) if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener) account.userProfile().unsubscribe(cacheListener)
} }
fun createFollowAccountsFilter(): JsonFilter { fun createFollowAccountsFilter(): JsonFilter {

View File

@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") { object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {

View File

@ -27,10 +27,9 @@ object Client: RelayPool.Listener {
private var relays = Constants.defaultRelays private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>() private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
fun connect( fun connect(relays: Array<Relay> = Constants.defaultRelays) {
relays: Array<Relay> = Constants.defaultRelays
) {
RelayPool.register(this) RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList()) RelayPool.loadRelays(relays.toList())
this.relays = relays this.relays = relays
} }

View File

@ -9,14 +9,18 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
class Relay( class Relay(
val url: String, var url: String,
var read: Boolean = true, var read: Boolean = true,
var write: Boolean = true var write: Boolean = true
) { ) {
private val httpClient = OkHttpClient() private val httpClient = OkHttpClient()
private var listeners = setOf<Listener>() private var listeners = setOf<Listener>()
private var socket: WebSocket? = null private var socket: WebSocket? = null
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
fun register(listener: Listener) { fun register(listener: Listener) {
listeners = listeners.plus(listener) listeners = listeners.plus(listener)
} }
@ -49,6 +53,7 @@ class Relay(
val channel = msg[1].asString val channel = msg[1].asString
when (type) { when (type) {
"EVENT" -> { "EVENT" -> {
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient) val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) } listeners.forEach { it.onEvent(this@Relay, channel, event) }
} }
@ -88,6 +93,8 @@ class Relay(
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorCounter++
socket?.close(1000, "Normal close") socket?.close(1000, "Normal close")
// Failures disconnect the relay. // Failures disconnect the relay.
socket = null socket = null
@ -98,6 +105,7 @@ class Relay(
} }
} }
} }
socket = httpClient.newWebSocket(request, listener) socket = httpClient.newWebSocket(request, listener)
} }
@ -108,14 +116,17 @@ class Relay(
} }
fun sendFilter(requestId: String) { fun sendFilter(requestId: String) {
if (socket == null) { if (read) {
requestAndWatch() if (socket == null) {
} else { requestAndWatch()
val filters = Client.getSubscriptionFilters(requestId) } else {
if (filters.isNotEmpty()) { val filters = Client.getSubscriptionFilters(requestId)
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""" if (filters.isNotEmpty()) {
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""") val request =
socket?.send(request) """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket?.send(request)
}
} }
} }
} }
@ -127,8 +138,10 @@ class Relay(
} }
fun send(signedEvent: Event) { fun send(signedEvent: Event) {
if (write) if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""") socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
}
} }
fun close(subscriptionId: String){ fun close(subscriptionId: String){

View File

@ -24,6 +24,10 @@ object RelayPool: Relay.Listener {
return relays.filter { it.isConnected() }.size return relays.filter { it.isConnected() }.size
} }
fun getRelay(url: String): Relay? {
return relays.firstOrNull() { it.url == url }
}
fun loadRelays(relayList: List<Relay>? = null){ fun loadRelays(relayList: List<Relay>? = null){
if (!relayList.isNullOrEmpty()){ if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) } relayList.forEach { addRelay(it) }

View File

@ -15,6 +15,8 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@ -68,24 +70,13 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Client.connect()
ServiceManager.start()
} }
override fun onPause() { override fun onPause() {
NostrAccountDataSource.stop() ServiceManager.pause()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
super.onPause() super.onPause()
} }
} }

View File

@ -0,0 +1,369 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
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.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account) {
val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
true
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
if (index == 0)
ServerConfigHeader()
ServerConfig(item,
onToggleDownload = {
postViewModel.toggleDownload(it)
},
onToggleUpload = {
postViewModel.toggleUpload(it)
},
onDelete = {
postViewModel.deleteRelay(it)
}
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig() {
postViewModel.addRelay(it)
}
}
}
}
}
@Composable
fun ServerConfigHeader() {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Relay Address",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Errors",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(20.dp))
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.url.removePrefix("wss://"),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = Color.Red
)
}
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun EditableServerConfig(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
var url by remember { mutableStateOf<String>("") }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Add a Relay") },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
IconButton(onClick = { read = !read }) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else Color.Red
)
}
IconButton(onClick = { write = !write }) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else Color.Red
)
}
Button(
onClick = {
if (url.isNotBlank()) {
val addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write))
url = ""
write = true
read = true
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Add", color = Color.White)
}
}
}
fun countToHumanReadable(counter: Int) = when {
counter >= 1000000000 -> "${round(counter/1000000000f)}G"
counter >= 1000000 -> "${round(counter/1000000f)}M"
counter >= 1000 -> "${round(counter/1000f)}k"
else -> "$counter"
}

View File

@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account) {
this.account = account
clear()
}
fun create() {
relays.let {
account.sendNewRelayList(it.value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
clear()
}
fun clear() {
_relays.update {
val relayFile = account.userProfile().relays
if (relayFile != null)
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter)
}.sortedBy { it.downloadCount }.reversed()
else
Constants.defaultRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter)
}
}
}
fun addRelay(relay: Relay) {
_relays.update {
it.plus(relay)
}
}
fun deleteRelay(relay: Relay) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }

View File

@ -1,7 +1,11 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -20,12 +24,17 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -46,6 +55,8 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -72,49 +83,92 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var wantsToEditRelays by remember {
mutableStateOf(false)
}
if (wantsToEditRelays)
NewRelayListView({ wantsToEditRelays = false }, account)
Column() { Column() {
TopAppBar( TopAppBar(
elevation = 0.dp, elevation = 0.dp,
backgroundColor = Color(0xFFFFFF), backgroundColor = Color(0xFFFFFF),
title = { title = {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.padding(start = 22.dp, end = 0.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
IconButton( Box() {
onClick = { Column(
Client.allSubscriptions().map { "${it} ${Client.getSubscriptionFilters(it).joinToString { it.toJson() }}" }.forEach { modifier = Modifier
Log.d("CURRENT FILTERS", it) .fillMaxWidth()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(
onClick = {
Client.allSubscriptions().map {
"${it} ${
Client.getSubscriptionFilters(it)
.joinToString { it.toJson() }
}"
}.forEach {
Log.d("CURRENT FILTERS", it)
}
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
}
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.End,
) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = {
wantsToEditRelays = true
}
)
)
} }
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
println("AAA: " + RelayPool.connectedRelays())
} }
) {
Icon(
painter = painterResource(R.drawable.amethyst),
null,
modifier = Modifier.size(40.dp),
tint = Color.Unspecified
)
} }
} }
}, },
@ -131,17 +185,13 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png", model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image", contentDescription = "Profile Image",
modifier = Modifier modifier = Modifier
.width(34.dp).height(34.dp) .width(34.dp)
.height(34.dp)
.clip(shape = CircleShape), .clip(shape = CircleShape),
) )
} }
}, },
actions = { actions = {
Text(
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
IconButton( IconButton(
onClick = {}, modifier = Modifier onClick = {}, modifier = Modifier
) { ) {

View File

@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toByteArray
@ -13,16 +15,24 @@ import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.MainActivity
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
import nostr.postr.toHex import nostr.postr.toHex
class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() { class AccountStateViewModel(
private val encryptedPreferences: EncryptedSharedPreferences
): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff) private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow() val accountContent = _accountContent.asStateFlow()
@ -65,19 +75,9 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
else else
_accountContent.update { AccountState.LoggedInViewOnly ( account ) } _accountContent.update { AccountState.LoggedInViewOnly ( account ) }
NostrAccountDataSource.account = account viewModelScope.launch(Dispatchers.IO) {
NostrHomeDataSource.account = account ServiceManager.start(account)
NostrNotificationDataSource.account = account }
NostrChatroomListDataSource.account = account
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} }
fun logOff() { fun logOff() {
@ -90,6 +90,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
encryptedPreferences.edit().apply { encryptedPreferences.edit().apply {
remove("nostr_privkey") remove("nostr_privkey")
remove("nostr_pubkey") remove("nostr_pubkey")
remove("following_channels")
}.apply() }.apply()
} }