diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt new file mode 100644 index 0000000000..881e8557be --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -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() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 004452884a..3ad45ee0e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -8,6 +8,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ReactionEvent 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 com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import java.util.Date import nostr.postr.Contact @@ -40,6 +42,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = return loggedIn.privKey != null } + fun sendNewRelayList(relays: Map) { + 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) { if (!isWriteable()) return @@ -234,6 +253,21 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = } } + fun activeRelays(): Array? { + 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. val live: AccountLiveData = AccountLiveData(this) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index b73243909f..f2e94107b6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature 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.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -19,6 +20,7 @@ import java.util.Collections import java.util.concurrent.ConcurrentHashMap import nostr.postr.events.ContactListEvent import nostr.postr.events.DeletionEvent +import nostr.postr.events.Event import nostr.postr.events.MetadataEvent import nostr.postr.events.PrivateDmEvent import nostr.postr.events.RecommendRelayEvent @@ -110,10 +112,10 @@ object LocalCache { // Adds notifications to users. mentions.forEach { - it.taggedPosts.add(note) + it.addTaggedPost(note) } replyTo.forEach { - it.author?.taggedPosts?.add(note) + it.author?.addTaggedPost(note) } // Counts the replies @@ -148,6 +150,26 @@ object LocalCache { 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 = + Event.gson.fromJson( + event.content, + object : TypeToken>() {}.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 } @@ -201,10 +223,10 @@ object LocalCache { // Adds notifications to users. mentions.forEach { - it.taggedPosts.add(note) + it.addTaggedPost(note) } repliesTo.forEach { - it.author?.taggedPosts?.add(note) + it.author?.addTaggedPost(note) } // Counts the replies @@ -231,10 +253,10 @@ object LocalCache { // Adds notifications to users. mentions.forEach { - it.taggedPosts.add(note) + it.addTaggedPost(note) } repliesTo.forEach { - it.author?.taggedPosts?.add(note) + it.author?.addTaggedPost(note) } if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") { @@ -312,10 +334,10 @@ object LocalCache { // Adds notifications to users. mentions.forEach { - it.taggedPosts.add(note) + it.addTaggedPost(note) } replyTo.forEach { - it.author?.taggedPosts?.add(note) + it.author?.addTaggedPost(note) } // Counts the replies diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 4991326219..8adbceaae1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData 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 java.util.Collections import java.util.concurrent.ConcurrentHashMap @@ -11,6 +13,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nostr.postr.events.ContactListEvent +import nostr.postr.events.Event import nostr.postr.events.MetadataEvent class User(val pubkey: ByteArray) { @@ -27,8 +30,11 @@ class User(val pubkey: ByteArray) { val notes = Collections.synchronizedSet(mutableSetOf()) val follows = Collections.synchronizedSet(mutableSetOf()) + val taggedPosts = Collections.synchronizedSet(mutableSetOf()) + var relays: Map? = null + val followers = Collections.synchronizedSet(mutableSetOf()) val messages = ConcurrentHashMap>() @@ -55,6 +61,10 @@ class User(val pubkey: ByteArray) { invalidateData() user.invalidateData() + + listeners.forEach { + it.onFollowsChange() + } } fun unfollow(user: User) { follows.remove(user) @@ -62,6 +72,15 @@ class User(val pubkey: ByteArray) { invalidateData() user.invalidateData() + + updateSubscribers { + it.onFollowsChange() + } + } + + fun addTaggedPost(note: Note) { + taggedPosts.add(note) + updateSubscribers { it.onNewPosts() } } @Synchronized @@ -76,6 +95,7 @@ class User(val pubkey: ByteArray) { fun addMessage(user: User, msg: Note) { getOrCreateChannel(user).add(msg) live.refresh() + updateSubscribers { it.onNewMessage() } } fun updateFollows(newFollows: Set, updateAt: Long) { @@ -95,6 +115,15 @@ class User(val pubkey: ByteArray) { updatedFollowsAt = updateAt } + fun updateRelays(relayUse: Map) { + if (relays != relayUse) { + relays = relayUse + listeners.forEach { + it.onRelayChange() + } + } + } + fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) { info = newUserInfo updatedMetadataAt = updateAt @@ -108,7 +137,42 @@ class User(val pubkey: ByteArray) { } } - // Observers line up here. + // Model Observers + private var listeners = setOf() + + 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) // Refreshes observers in batches. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 7ba2fa2ca2..32f28b1f76 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -13,27 +13,11 @@ import nostr.postr.events.TextNoteEvent object NostrAccountDataSource: NostrDataSource("AccountData") { 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 { return JsonFilter( kinds = listOf(ContactListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - limit = 1 + limit = 5 ) } @@ -41,7 +25,7 @@ object NostrAccountDataSource: NostrDataSource("AccountData") { return JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(account.userProfile().pubkeyHex), - limit = 3 + limit = 5 ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index a752aba315..222b4085c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -3,8 +3,12 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState 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.events.TextNoteEvent import nostr.postr.toHex @@ -12,20 +16,22 @@ import nostr.postr.toHex object NostrHomeDataSource: NostrDataSource("HomeFeed") { lateinit var account: Account - private val cacheListener: (UserState) -> Unit = { - resetFilters() + object cacheListener: User.Listener() { + override fun onFollowsChange() { + resetFilters() + } } override fun start() { if (this::account.isInitialized) - account.userProfile().live.observeForever(cacheListener) + account.userProfile().subscribe(cacheListener) super.start() } override fun stop() { super.stop() if (this::account.isInitialized) - account.userProfile().live.removeObserver(cacheListener) + account.userProfile().unsubscribe(cacheListener) } fun createFollowAccountsFilter(): JsonFilter { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt index 1c1303df89..4d8067a70f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrNotificationDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import nostr.postr.JsonFilter object NostrNotificationDataSource: NostrDataSource("NotificationFeed") { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index cb9fe10659..5881bb1750 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -27,10 +27,9 @@ object Client: RelayPool.Listener { private var relays = Constants.defaultRelays private val subscriptions = mutableMapOf>() - fun connect( - relays: Array = Constants.defaultRelays - ) { + fun connect(relays: Array = Constants.defaultRelays) { RelayPool.register(this) + RelayPool.unloadRelays() RelayPool.loadRelays(relays.toList()) this.relays = relays } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 6aeef1e44d..1963c697bf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -9,14 +9,18 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener class Relay( - val url: String, - var read: Boolean = true, - var write: Boolean = true + var url: String, + var read: Boolean = true, + var write: Boolean = true ) { private val httpClient = OkHttpClient() private var listeners = setOf() private var socket: WebSocket? = null + var eventDownloadCounter = 0 + var eventUploadCounter = 0 + var errorCounter = 0 + fun register(listener: Listener) { listeners = listeners.plus(listener) } @@ -49,6 +53,7 @@ class Relay( val channel = msg[1].asString when (type) { "EVENT" -> { + eventDownloadCounter++ val event = Event.fromJson(msg[2], Client.lenient) listeners.forEach { it.onEvent(this@Relay, channel, event) } } @@ -88,6 +93,8 @@ class Relay( } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + errorCounter++ + socket?.close(1000, "Normal close") // Failures disconnect the relay. socket = null @@ -98,6 +105,7 @@ class Relay( } } } + socket = httpClient.newWebSocket(request, listener) } @@ -108,14 +116,17 @@ class Relay( } fun sendFilter(requestId: String) { - if (socket == null) { - requestAndWatch() - } else { - val filters = Client.getSubscriptionFilters(requestId) - if (filters.isNotEmpty()) { - val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""" - //println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""") - socket?.send(request) + if (read) { + if (socket == null) { + requestAndWatch() + } else { + val filters = Client.getSubscriptionFilters(requestId) + if (filters.isNotEmpty()) { + val 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) { - if (write) + if (write) { socket?.send("""["EVENT",${signedEvent.toJson()}]""") + eventUploadCounter++ + } } fun close(subscriptionId: String){ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index fecb3d11ea..ee3a3670dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -24,6 +24,10 @@ object RelayPool: Relay.Listener { return relays.filter { it.isConnected() }.size } + fun getRelay(url: String): Relay? { + return relays.firstOrNull() { it.url == url } + } + fun loadRelays(relayList: List? = null){ if (!relayList.isNullOrEmpty()){ relayList.forEach { addRelay(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 00a3f8d7cf..21f9bb0814 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -15,6 +15,8 @@ import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder 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.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource @@ -68,24 +70,13 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() - Client.connect() + + ServiceManager.start() } override fun onPause() { - NostrAccountDataSource.stop() - NostrHomeDataSource.stop() - NostrChannelDataSource.stop() - NostrChatroomListDataSource.stop() - NostrUserProfileDataSource.stop() - NostrUserProfileFollowersDataSource.stop() - NostrUserProfileFollowsDataSource.stop() + ServiceManager.pause() - NostrGlobalDataSource.stop() - NostrNotificationDataSource.stop() - NostrSingleEventDataSource.stop() - NostrSingleUserDataSource.stop() - NostrThreadDataSource.stop() - Client.disconnect() super.onPause() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt new file mode 100644 index 0000000000..3fef5db616 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListView.kt @@ -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("") } + 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" +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt new file mode 100644 index 0000000000..7fd931b76a --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -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>(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 Iterable.updated(old: T, new: T): List = map { if (it == old) new else it } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 09e6b7232c..a2606cd490 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -1,7 +1,11 @@ package com.vitorpamplona.amethyst.ui.navigation 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.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.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 androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.lifecycle.viewmodel.compose.viewModel 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.relays.Client 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.loggedIn.AccountViewModel import kotlinx.coroutines.launch @@ -72,49 +83,92 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) val coroutineScope = rememberCoroutineScope() + var wantsToEditRelays by remember { + mutableStateOf(false) + } + + if (wantsToEditRelays) + NewRelayListView({ wantsToEditRelays = false }, account) + Column() { TopAppBar( elevation = 0.dp, backgroundColor = Color(0xFFFFFF), title = { Column( - modifier = Modifier - .padding(start = 22.dp, end = 0.dp) - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - IconButton( - onClick = { - Client.allSubscriptions().map { "${it} ${Client.getSubscriptionFilters(it).joinToString { it.toJson() }}" }.forEach { - Log.d("CURRENT FILTERS", it) + Box() { + Column( + modifier = Modifier + .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", contentDescription = "Profile Image", modifier = Modifier - .width(34.dp).height(34.dp) + .width(34.dp) + .height(34.dp) .clip(shape = CircleShape), ) } }, actions = { - Text( - "${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}", - color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - IconButton( onClick = {}, modifier = Modifier ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 80d4d75c91..e7a8d112ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -1,7 +1,9 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.security.crypto.EncryptedSharedPreferences +import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.DefaultChannels 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.NostrSingleUserDataSource 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 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.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import nostr.postr.Persona import nostr.postr.bechToBytes import nostr.postr.toHex -class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() { +class AccountStateViewModel( + private val encryptedPreferences: EncryptedSharedPreferences +): ViewModel() { private val _accountContent = MutableStateFlow(AccountState.LoggedOff) val accountContent = _accountContent.asStateFlow() @@ -65,19 +75,9 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre else _accountContent.update { AccountState.LoggedInViewOnly ( account ) } - NostrAccountDataSource.account = account - NostrHomeDataSource.account = account - NostrNotificationDataSource.account = account - NostrChatroomListDataSource.account = account - - NostrAccountDataSource.start() - NostrGlobalDataSource.start() - NostrHomeDataSource.start() - NostrNotificationDataSource.start() - NostrSingleEventDataSource.start() - NostrSingleUserDataSource.start() - NostrThreadDataSource.start() - NostrChatroomListDataSource.start() + viewModelScope.launch(Dispatchers.IO) { + ServiceManager.start(account) + } } fun logOff() { @@ -90,6 +90,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre encryptedPreferences.edit().apply { remove("nostr_privkey") remove("nostr_pubkey") + remove("following_channels") }.apply() }