Compare commits

..
44 Commits
Author SHA1 Message Date
Vitor Pamplona cae9502d11 v0.10.3 2023-01-24 16:59:31 -03:00
Vitor Pamplona 687428abc1 Support for Blocking and Reporting Users/Posts 2023-01-24 16:59:21 -03:00
Vitor Pamplona 45ea408877 Updating assets for fastlane 2023-01-23 21:18:16 -03:00
Vitor PamplonaandGitHub c863b6c5e5 Merge pull request #5 from IzzySoft/fastlane
initial fastlane structure
2023-01-23 19:10:29 -05:00
Vitor Pamplona 34dd009246 v0.10.2 2023-01-23 20:58:36 -03:00
Vitor Pamplona e064741148 Moving coroutines from Main to Default, which is more ideal for memory intensive tasks 2023-01-23 20:54:56 -03:00
Vitor Pamplona b6c25cfa46 Not updating observers if the database doesn't change. 2023-01-23 20:54:10 -03:00
Vitor Pamplona 114f064bbf Fixes a bug in updating a list of relays after a bug from nostr-postr 2023-01-23 20:53:37 -03:00
Vitor Pamplona aeb78b293e No more black profile pictures while loading 2023-01-23 20:32:10 -03:00
Vitor Pamplona 2b4a1a3966 Updating dependencies 2023-01-23 20:30:40 -03:00
Vitor Pamplona 582f8e39c0 Releasing a new version to make sure versionCode was updated. 2023-01-23 16:30:18 -03:00
Vitor Pamplona a6c3c5c606 Delete old logs 2023-01-23 15:16:26 -03:00
Vitor Pamplona b641678222 migrating v0l to snort 2023-01-23 15:02:36 -03:00
Vitor Pamplona 33fc47f1aa v.0.10.0 2023-01-23 14:30:33 -03:00
Vitor Pamplona 8d3d543318 Avoids duplicating urls in relay list add. 2023-01-23 14:11:13 -03:00
Vitor Pamplona a47aaab83c Relay Management (View/Edit) 2023-01-23 13:58:06 -03:00
Vitor Pamplona c33f7f615f Avoiding recurring inner note rendering. 2023-01-22 19:07:32 -03:00
Vitor Pamplona 0c2fd3adc0 Doesn't try to link phone numbers smaller than 6 chars 2023-01-22 19:07:06 -03:00
Vitor Pamplona a366809319 Search by partial Hex or full npub/note activated. 2023-01-22 18:35:10 -03:00
Vitor Pamplona 7baef64af9 Single line about me texts in the follow list 2023-01-22 18:34:41 -03:00
Vitor Pamplona 9c04cb0e30 putting synchronized lists into a synchronized method 2023-01-22 18:34:21 -03:00
Vitor Pamplona d13979b4b5 Marking Chat groups as such in the UI 2023-01-22 18:33:38 -03:00
Vitor Pamplona 9f15b87d57 Allowing unloaded Notes to go into the feed and be loaded later 2023-01-22 18:33:22 -03:00
Vitor Pamplona 59305c9b15 Using full pubkeys in User requests 2023-01-22 18:32:22 -03:00
Vitor Pamplona 53eeba210a Start of the work to have following since <date> 2023-01-22 18:31:23 -03:00
Vitor Pamplona 38c17f9024 Unnecessary Regex compile 2023-01-22 18:29:59 -03:00
Vitor Pamplona 0c96d0a3fd 0.9.6 2023-01-21 21:14:19 -03:00
Vitor Pamplona 7be2d08db1 View Count 2023-01-21 21:14:10 -03:00
Vitor PamplonaandGitHub 0a02c66c5a Merge pull request #7 from erjanmx/fix-readme-typo
Fix readme typo
2023-01-21 14:01:12 -05:00
Erjan Kalybek 261eecbcc3 Fix readme typo 2023-01-21 20:00:19 +01:00
Vitor Pamplona f17936f029 Version 0.9.5 2023-01-21 13:53:48 -03:00
Vitor Pamplona d64766516a Removing unnecessary observables 2023-01-21 13:53:38 -03:00
Vitor Pamplona d685219ed9 Remove blinking 2023-01-21 13:53:12 -03:00
Vitor Pamplona bb9217466a Hard Refresh when the button to refresh is pressed. 2023-01-21 13:37:57 -03:00
Vitor Pamplona 80f58875bb Uses cached images instead of reloading them by default 2023-01-21 13:32:22 -03:00
Vitor Pamplona ffe58f3d41 Fixing click home button to go to the top of the home feed. 2023-01-21 13:07:47 -03:00
Vitor Pamplona 733801fddf fixing alignment of the reaction buttons 2023-01-21 12:48:14 -03:00
Vitor Pamplona 5cb6bca224 Fixing concurrent modification exception in reactions and boosts 2023-01-21 12:41:05 -03:00
Vitor Pamplona 790dd778bb Fixing quote of the root image in Messages 2023-01-21 12:40:49 -03:00
Vitor Pamplona a692ad47d6 Spam filter in new private messages 2023-01-21 12:31:23 -03:00
Vitor Pamplona 46a8f81387 Adding more logo versions and store banners 2023-01-21 10:58:54 -03:00
Vitor Pamplona acb07352b7 Updated Screenshots 2023-01-21 10:56:24 -03:00
Vitor PamplonaandGitHub 5268da117c Updating Privacy policy
Adding Data deletion path and contact info
2023-01-21 08:54:46 -05:00
Izzy de6c594d5a initial fastlane structure 2023-01-20 21:58:28 +01:00
83 changed files with 1789 additions and 467 deletions
+4 -1
View File
@@ -2,7 +2,10 @@
Effective as of Jan 20, 2023.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone before it's shared with third-party Nostr servers. Accounts are fully maintained by the user. We do not have control over them.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
+1 -1
View File
@@ -19,7 +19,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Public Chats (NIP-28)
- [ ] Notification Bubbles
- [ ] Infinity Scroll
- [ ] Dropdown to Link Users/Posts when writting
- [ ] Dropdown to Link Users/Posts when writing
- [ ] Identity Verification (NIP-05)
- [ ] Event Delegation (NIP-09)
- [ ] Filter messages from Unknown People
+5 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 15
versionName "0.9.4"
versionCode 21
versionName "0.10.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -64,13 +64,13 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -79,7 +79,7 @@ dependencies {
exclude module: 'guava-testlib'
}
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
@@ -4,9 +4,9 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class KeyStorage {
class EncryptedStorage {
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
fun preferences(context: Context): EncryptedSharedPreferences {
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
val preferencesName = "secret_keeper"
@@ -0,0 +1,50 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
import nostr.postr.Persona
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers
)
} else {
return null
}
}
}
}
@@ -0,0 +1,78 @@
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
NostrGlobalDataSource.account = myAccount
NostrChannelDataSource.account = myAccount
NostrUserProfileDataSource.account = myAccount
NostrUserProfileFollowsDataSource.account = myAccount
NostrUserProfileFollowersDataSource.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()
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -128,7 +129,7 @@ object LnInvoiceUtil {
}
private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier) {
return when (multiplier.toLowerCase()) {
"m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001")
@@ -8,8 +8,14 @@ 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.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import java.util.Date
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
@@ -25,8 +31,11 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
var seeReplies: Boolean = true
class Account(
val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = DefaultChannels.toMutableSet()
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
@@ -36,10 +45,31 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
}
fun isWriteable(): Boolean {
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) {
if (!isWriteable()) return
@@ -60,7 +90,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() } != null) {
if (note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+" } != null) {
// has already liked this note
return
}
@@ -72,6 +102,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
}
}
fun report(note: Note) {
if (!isWriteable()) return
if (
note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null
) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun boost(note: Note) {
if (!isWriteable()) return
@@ -93,10 +140,17 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!)
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
relays,
loggedIn.privKey!!
)
}
Client.send(event)
@@ -108,7 +162,10 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -157,7 +214,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
LocalCache.consume(signedEvent)
}
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) {
fun sendCreateNewChannel(name: String, about: String, picture: String) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
@@ -172,17 +229,25 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex(), accountStateViewModel)
joinChannel(event.id.toHex())
}
fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
fun joinChannel(idHex: String) {
followingChannels.add(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
fun leaveChannel(idHex: String) {
followingChannels.remove(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
fun hideUser(pubkeyHex: String) {
hiddenUsers.add(pubkeyHex)
invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers.remove(pubkeyHex)
invalidateData()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -234,28 +299,61 @@ 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() {
Client.disconnect()
Client.connect(activeRelays() ?: Constants.defaultRelays)
RelayPool.requestAndWatch()
}
})
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
private fun refreshObservers() {
live.refresh()
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post
&& note.reports.filter { it.author in userProfile().follows }.size < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is ReactionEvent
|| (note.event is ReactionEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } == null)
) // is not a reaction about a blocked post
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
fun refresh() {
postValue(AccountState(account))
}
override fun onActive() {
super.onActive()
}
override fun onInactive() {
super.onInactive()
}
}
class AccountState(val account: Account)
@@ -26,8 +26,6 @@ fun HexKey.toDisplayHexKey(): String {
}
fun decodePublicKey(key: String): ByteArray {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
@@ -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
@@ -140,18 +142,29 @@ object LocalCache {
getOrCreateUser(pubKey)
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println(event.toJson())
println("UpdateFollows: " + event.toJson())
e.printStackTrace()
null
}
}.filterNotNull(),
}.filterNotNull().toSet(),
event.createdAt
)
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
e.printStackTrace()
}
user.latestContactList = event
}
refreshObservers()
}
fun consume(event: PrivateDmEvent) {
@@ -201,10 +214,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 +244,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") {
@@ -243,6 +256,13 @@ object LocalCache {
it.addReaction(note)
}
}
if (event.content == "\u26A0\uFE0F") {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
}
}
fun consume(event: ChannelCreateEvent) {
@@ -257,12 +277,12 @@ object LocalCache {
val note = oldChannel.getOrCreateNote(event.id.toHex())
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
refreshObservers()
}
} else {
// older data, does nothing
}
refreshObservers()
}
fun consume(event: ChannelMetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
@@ -278,12 +298,12 @@ object LocalCache {
val note = oldChannel.getOrCreateNote(event.id.toHex())
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
refreshObservers()
}
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
refreshObservers()
}
fun consume(event: ChannelMessageEvent) {
@@ -298,7 +318,12 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { channel.getOrCreateNote(it) }.toMutableList())
val replyTo = Collections.synchronizedList(
event.replyTos
.map { channel.getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
.toMutableList()
)
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
@@ -307,10 +332,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
@@ -35,6 +35,8 @@ class Note(val idHex: String) {
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
@@ -88,12 +90,21 @@ class Note(val idHex: String) {
invalidateData()
}
fun addReport(note: Note) {
if (reports.add(note))
invalidateData()
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
return synchronized(reactions) {
reactions.any { it.author == user }
}
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author == user }
return synchronized(boosts) {
boosts.any { it.author == user }
}
}
// Observers line up here.
@@ -106,7 +117,7 @@ class Note(val idHex: String) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
@@ -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<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var relays: Map<String, ContactListEvent.ReadWrite>? = null
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
@@ -49,12 +55,16 @@ class User(val pubkey: ByteArray) {
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
}
fun follow(user: User) {
fun follow(user: User, followedAt: Long) {
follows.add(user)
user.followers.add(this)
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,9 +95,10 @@ class User(val pubkey: ByteArray) {
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
live.refresh()
updateSubscribers { it.onNewMessage() }
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
}
@@ -86,7 +106,7 @@ class User(val pubkey: ByteArray) {
follows - newFollows
}
toBeAdded.forEach {
follow(it)
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
@@ -95,6 +115,15 @@ class User(val pubkey: ByteArray) {
updatedFollowsAt = updateAt
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
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<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)
// Refreshes observers in batches.
@@ -118,7 +182,7 @@ class User(val pubkey: ByteArray) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
@@ -7,7 +7,7 @@ object Constants {
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
Relay("wss://brb.io", read = true, write = true),
Relay("wss://nostr.v0l.io", read = true, write = true),
Relay("wss://relay.snort.social", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
@@ -13,27 +13,11 @@ import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("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<Note>("AccountData") {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 3
limit = 5
)
}
@@ -1,11 +1,13 @@
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.service.model.ChannelMessageEvent
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
@@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList()
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
@@ -33,7 +33,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
val messages = account.userProfile().messages[withUser]?.filter { account.isAcceptable(it) }
return messages?.sortedBy { it.event?.createdAt } ?: emptyList()
}
@@ -64,14 +64,14 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
val channelsCreatedByMe = LocalCache.channels.values.filter {
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
limit = 50
@@ -14,6 +16,7 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
}
@@ -0,0 +1,16 @@
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 nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
override fun updateChannelFilters() {}
}
@@ -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<Note>("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 {
@@ -76,6 +82,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -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<Note>("NotificationFeed") {
@@ -17,7 +18,7 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }
set.filter { it.event != null }.filter { account.isAcceptable(it) }
}
return filtered.sortedBy { it.event?.createdAt }.reversed()
@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<JsonFilter>? {
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
JsonFilter(
ids = listOfNotNull(hexToWatch)
),
JsonFilter(
authors = listOfNotNull(hexToWatch)
)
)
}
val searchChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList<Note>()
}
override fun updateChannelFilters() {
searchChannel.filter = createAnythingWithIDFilter()
}
fun search(eventId: String) {
try {
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
decodePublicKey(eventId).toHex()
} else if (eventId.startsWith("note")) {
eventId.bechToBytes().toHex()
} else {
eventId
}
hexToWatch = hex
invalidateFilters()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
}
}
fun clear() {
hexToWatch = null
}
}
@@ -27,18 +27,18 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val directEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex }
.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
@@ -47,7 +47,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
ids = interestedEvents
ids = interestedEvents.toList()
)
}
@@ -55,9 +55,11 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -2,11 +2,12 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
fun createUserFilter(): List<JsonFilter>? {
@@ -15,7 +16,7 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
return usersToWatch.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
authors = listOf(it),
limit = 1
)
}
@@ -23,10 +24,12 @@ object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
val userChannel = requestNewChannel()
override fun feed(): List<Note> {
return usersToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
override fun feed(): List<User> {
return synchronized(usersToWatch) {
usersToWatch.map {
LocalCache.users[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -44,9 +44,11 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
@@ -1,5 +1,6 @@
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
@@ -8,6 +9,7 @@ import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@@ -37,7 +39,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.sortedBy { it.event?.createdAt }
notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
}
return sortedNotes.reversed()
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@@ -24,7 +26,7 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.toList()
followers.filter { account.isAcceptable(it) }.toList()
}
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
@@ -24,7 +26,11 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
val followChannel = requestNewChannel()
override fun feed(): List<User> {
return user?.follows?.toList() ?: emptyList()
val follows = user?.follows ?: emptyList()
return synchronized(follows) {
follows.filter { account.isAcceptable(it) }.toList()
}
}
override fun updateChannelFilters() {
@@ -25,6 +25,10 @@ class ReactionEvent (
companion object {
const val kind = 7
fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
@@ -27,10 +27,9 @@ object Client: RelayPool.Listener {
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
fun connect(
relays: Array<Relay> = Constants.defaultRelays
) {
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
}
@@ -1,7 +1,11 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.model.LocalCache
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toNpub
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -9,14 +13,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<Listener>()
private var socket: WebSocket? = null
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
fun register(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -30,7 +38,6 @@ class Relay(
}
fun requestAndWatch() {
println("Connecting with ${url}")
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
@@ -49,6 +56,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,16 +96,19 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorCounter++
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
//println("Relay onFailure ${url}, ${response?.message}")
t.printStackTrace()
Log.w("Relay", "Relay onFailure ${url}, ${response?.message}")
//t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
}
socket = httpClient.newWebSocket(request, listener)
}
@@ -108,14 +119,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 +141,10 @@ class Relay(
}
fun send(signedEvent: Event) {
if (write)
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
}
}
fun close(subscriptionId: String){
@@ -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<Relay>? = null){
if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) }
@@ -14,19 +14,9 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
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.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -44,7 +34,9 @@ class MainActivity : ComponentActivity() {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.build()
}
.respectCacheHeaders(false)
.build()
}
setContent {
@@ -53,7 +45,7 @@ class MainActivity : ComponentActivity() {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
AccountStateViewModel(LocalPreferences(applicationContext))
}
AccountScreen(accountViewModel)
@@ -66,24 +58,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()
}
}
@@ -15,6 +15,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -26,10 +27,11 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) {
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
val context = LocalContext.current.applicationContext
postViewModel.load(account, channel, accountStateViewModel)
postViewModel.load(account, channel)
Dialog(
onDismissRequest = { onClose() },
@@ -55,7 +57,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
PostButton(
onPost = {
postViewModel.create()
postViewModel.create(context)
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
@@ -1,25 +1,26 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
private var accountStateViewModel: AccountStateViewModel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
fun load(account: Account, channel: Channel?) {
this.account = account
if (channel != null) {
originalChannel = channel
@@ -29,21 +30,24 @@ class NewChannelViewModel: ViewModel() {
}
}
fun create() {
if (originalChannel == null)
this.account?.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
accountStateViewModel!!
)
else
this.account?.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
fun create(context: Context) {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
clear()
}
@@ -23,6 +23,7 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -67,7 +68,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = FocusRequester()
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
@@ -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"
}
@@ -0,0 +1,94 @@
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) {
if (relays.value.filter { it.url == relay.url }.isNotEmpty()) return
_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 }
@@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) {
fun NewChannelButton(account: Account) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel)
NewChannelView({ wantsToPost = false }, account = account)
OutlinedButton(
onClick = { wantsToPost = true },
@@ -62,7 +62,7 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches()) {
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
@@ -59,9 +59,10 @@ fun AppBottomBar(navController: NavHostController) {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
@@ -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,16 +24,22 @@ 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
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -46,6 +56,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 +84,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
)
}
}
},
@@ -129,19 +184,16 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.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
) {
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -117,6 +118,7 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldSt
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
@@ -160,6 +162,8 @@ fun ListContent(
modifier: Modifier,
accountViewModel: AccountStateViewModel
) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) {
LazyColumn() {
item {
@@ -176,14 +180,19 @@ fun ListContent(
thickness = 0.25.dp
)
Column(modifier = modifier.padding(horizontal = 25.dp)) {
Text(
text = "Settings",
fontSize = 18.sp,
fontWeight = W500
)
Row(
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }),
) {
Row(modifier = Modifier.clickable(onClick = {
navController.navigate(Route.Filters.route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Text(
text = "Security Filters",
fontSize = 18.sp,
fontWeight = W500
)
}
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
Text(
text = "Log out",
modifier = Modifier.padding(vertical = 15.dp),
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
@@ -32,6 +33,7 @@ sealed class Route(
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }})
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }})
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }})
object Filters : Route("Filters", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }})
object Profile : Route("User/{id}", R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
@@ -65,7 +67,8 @@ val Routes = listOf(
Route.Profile,
Route.Note,
Route.Room,
Route.Channel
Route.Channel,
Route.Filters
)
@Composable
@@ -26,7 +26,38 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Referenced event not found",
text = "Referenced post not found",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
}
}
}
}
@Composable
fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
Row(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp,
bottom = 25.dp,
top = 15.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Post was flagged as inappropriate",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
@@ -67,6 +68,7 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc
AsyncImage(
model = userState?.user?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${userState?.user?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
@@ -17,11 +17,13 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -57,12 +59,17 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
channel?.let { channel ->
ChannelName(
channelPicture = channel.profilePicture(),
channelPicturePlaceholder = null,
channelTitle = {
Text(
"${channel.info.name}",
fontWeight = FontWeight.Bold,
modifier = it
)
Text(
" Public Chat",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
channelLastTime = note.event?.createdAt,
channelLastContent = "${author?.toBestDisplayName()}: " + description,
@@ -89,6 +96,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
userToComposeOn?.let { user ->
ChannelName(
channelPicture = user.profilePicture(),
channelPicturePlaceholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = note.event?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
@@ -101,6 +109,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
@Composable
fun ChannelName(
channelPicture: String,
channelPicturePlaceholder: Painter?,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
@@ -113,6 +122,7 @@ fun ChannelName(
AsyncImage(
model = channelPicture,
placeholder = channelPicturePlaceholder,
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -116,6 +118,7 @@ fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountV
) {
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(25.dp)
@@ -141,15 +144,16 @@ fun ChatroomMessageCompose(baseNote: Note, innerQuote: Boolean = false, accountV
}
val replyTo = note.replyTo
if (replyTo != null && replyTo.isNotEmpty()) {
if (!innerQuote && replyTo != null && replyTo.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
replyTo.mapIndexed { index, note ->
ChatroomMessageCompose(
note,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController
)
replyTo.toSet().mapIndexed { index, note ->
if (note.event != null)
ChatroomMessageCompose(
note,
innerQuote = true,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
@@ -67,6 +68,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
AsyncImage(
model = userState?.user?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${userState?.user?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -27,11 +28,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -45,6 +48,9 @@ import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
@@ -55,6 +61,11 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (account?.isAcceptable(note) == false) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
@@ -87,6 +98,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
AsyncImage(
model = author?.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
modifier = Modifier
.width(55.dp).height(55.dp)
.clip(shape = CircleShape)
@@ -103,6 +115,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
AsyncImage(
model = boostedPosts[0].author?.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${boostedPosts[0].author?.pubkeyHex}.png"),
modifier = Modifier
.width(35.dp).height(35.dp)
.clip(shape = CircleShape)
@@ -192,6 +205,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current.applicationContext
DropdownMenu(
expanded = popupExpanded,
@@ -210,5 +224,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) {
Text("Report Post")
}
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
}
}
@@ -1,33 +1,54 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.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.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.imageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (Note) -> Unit) {
fun ReactionsRow(note: Note, account: Account, accountViewModel: AccountViewModel) {
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var popupExpanded by remember { mutableStateOf(false) }
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -35,105 +56,130 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
if (wantsToReplyTo != null)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
Row(modifier = Modifier.padding(top = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
) {
Row(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
}
Text(
" ${showCount(note.replies?.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.boost(note) }
) {
if (note.isBoostedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_comment),
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
Text(
" ${showCount(note.replies?.size)}",
fontSize = 14.sp,
color = grayTint
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) boost(note) }
) {
if (note.isBoostedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(note.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) reactTo(note) }
) {
if (note.isReactedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(note.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
Text(
" ${showCount(note.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Row(
modifier = Modifier.padding(start = 40.dp),
verticalAlignment = Alignment.CenterVertically
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { if (account.isWriteable()) accountViewModel.reactTo(note) }
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
) {
if (note.isReactedBy(account.userProfile())) {
Icon(
painter = painterResource(R.drawable.ic_share),
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(note.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
tint = grayTint
)
}
Row(modifier = Modifier.weight(1f)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${note.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "View count",
modifier = Modifier.height(24.dp),
colorFilter = ColorFilter.tint(grayTint)
)
}
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { popupExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
fun showCount(size: Int?): String {
@@ -16,5 +16,5 @@ fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
if (account == null || note == null) return
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo)
ReactionsRow(note, account, accountViewModel)
}
@@ -16,21 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FollowButton
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${user.pubkeyHex}") }
@@ -46,6 +54,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
AsyncImage(
model = user.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
@@ -58,16 +67,23 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
user.info.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (accountState?.account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { accountState?.account?.unfollow(user) }
if (account?.isAcceptable(user) == false) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { accountState?.account?.follow(user) }
FollowButton { account?.follow(user) }
}
}
}
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
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
@@ -14,22 +16,28 @@ 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 localPreferences: LocalPreferences): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
init {
// pulls account from storage.
loadFromEncryptedStorage()?.let {
localPreferences.loadFromEncryptedStorage()?.let {
login(it)
}
}
@@ -49,14 +57,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
Account(Persona(Hex.decode(key)))
}
saveToEncryptedStorage(account)
localPreferences.saveToEncryptedStorage(account)
login(account)
}
fun newKey() {
val account = Account(Persona())
saveToEncryptedStorage(account)
localPreferences.saveToEncryptedStorage(account)
login(account)
}
@@ -66,53 +74,14 @@ 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() {
_accountContent.update { AccountState.LoggedOff }
clearEncryptedStorage()
}
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", account.followingChannels) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
if (pubKey != null) {
return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels)
} else {
return null
}
}
localPreferences.clearEncryptedStorage()
}
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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
@@ -24,7 +25,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
@@ -27,7 +27,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private var lastNotes: List<Note>? = null
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
@@ -92,16 +92,17 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
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
@@ -30,7 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
@@ -39,7 +40,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.refresh()
viewModel.hardRefresh()
isRefreshing = false
}
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -15,10 +14,6 @@ import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
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 kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -31,21 +26,46 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatroomListFeedViewModel: FeedViewModel(NostrChatroomListDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource)
class NostrGlobalFeedViewModel: FeedViewModel(NostrGlobalDataSource)
class NostrThreadFeedViewModel: FeedViewModel(NostrThreadDataSource)
class NostrUserProfileFeedViewModel: FeedViewModel(NostrUserProfileDataSource)
class NostrChatroomListKnownFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: all channels + PMs the account has replied to
return super.newListFromDataSource().filter {
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.messages[it.author]?.firstOrNull { me == it.author } != null
}
}
}
class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no channels + PMs the account has never replied to
return super.newListFromDataSource().filter {
val me = NostrChatroomListDataSource.account.userProfile()
it.channel == null && me.messages[it.author]?.firstOrNull { me == it.author } == null
}
}
}
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
open fun newListFromDataSource(): List<Note> {
return dataSource.loadTop()
}
fun hardRefresh() {
dataSource.resetFilters()
}
fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
val notes = dataSource.loadTop()
viewModelScope.launch(Dispatchers.Default) {
val notes = newListFromDataSource()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
@@ -71,16 +91,17 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
@@ -176,6 +177,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
// Draws the boosted picture outside the boosted card.
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp).height(55.dp)
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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
@@ -22,7 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import kotlinx.coroutines.CoroutineScope
@@ -28,12 +29,16 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowersDataSource
)
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
NostrHiddenAccountsDataSource
)
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
@@ -64,16 +69,17 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
synchronized(handlerWaiting) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@@ -1,11 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
class AccountViewModel(private val account: Account): ViewModel() {
@@ -15,6 +18,10 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.reactTo(note)
}
fun report(note: Note) {
account.report(note)
}
fun boost(note: Note) {
account.boost(note)
}
@@ -26,4 +33,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun decrypt(note: Note): String? {
return account.decryptContent(note)
}
fun hide(user: User, ctx: Context) {
account.hideUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
fun show(user: User, ctx: Context) {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
}
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -40,6 +41,8 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
@@ -135,6 +138,7 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
AsyncImage(
model = channel?.profilePicture(),
placeholder = null,
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp)
@@ -167,12 +171,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it, accountStateViewModel)
EditButton(account, it)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account,channel,accountStateViewModel, navController)
LeaveButton(account,channel, navController)
} else {
JoinButton(account,channel,accountStateViewModel, navController)
JoinButton(account,channel, navController)
}
}
}
@@ -207,13 +211,13 @@ private fun NoteCopyButton(
}
@Composable
private fun EditButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel) {
private fun EditButton(account: Account, channel: Channel) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel, channel)
NewChannelView({ wantsToPost = false }, account = account, channel)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
@@ -229,11 +233,14 @@ private fun EditButton(account: Account, channel: Channel, accountStateViewModel
}
@Composable
private fun JoinButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
private fun JoinButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.joinChannel(channel.idHex, accountStateViewModel)
account.joinChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
@@ -247,11 +254,14 @@ private fun JoinButton(account: Account, channel: Channel, accountStateViewModel
}
@Composable
private fun LeaveButton(account: Account, channel: Channel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
private fun LeaveButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.leaveChannel(channel.idHex, accountStateViewModel)
account.leaveChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
@@ -3,35 +3,96 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
if (account != null) {
val feedViewModel: NostrChatroomListFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Known")
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "New Requests")
}
)
}
HorizontalPager(count = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabKnown(accountViewModel, navController)
1 -> TabNew(accountViewModel, navController)
}
}
}
}
}
@Composable
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@Composable
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
}
}
}
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
@@ -116,6 +117,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account
if (account != null) {
NostrHiddenAccountsDataSource.account = account
val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel()
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
TabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
color = MaterialTheme.colors.primary
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
Text(text = "Blocked Users")
}
)
}
HorizontalPager(count = 1, state = pagerState) {
when (pagerState.currentPage) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
}
}
}
}
}
}
@@ -22,21 +22,17 @@ import java.lang.System.currentTimeMillis
@Composable
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val feedViewModel: NostrHomeFeedViewModel = viewModel()
if (accountState != null) {
val feedViewModel: NostrHomeFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController)
}
}
}
@@ -85,7 +85,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
// Does nothing.
}
is AccountState.LoggedIn -> {
NewChannelButton(state.account, accountViewModel)
NewChannelButton(state.account)
}
}
}
@@ -16,17 +16,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
val account by accountViewModel.accountLiveData.observeAsState()
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
if (account != null) {
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
}
}
}
@@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -49,10 +50,12 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -66,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import nostr.postr.toNsec
@OptIn(ExperimentalPagerApi::class)
@Composable
@@ -76,6 +80,8 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val ctx = LocalContext.current.applicationContext
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
@@ -130,6 +136,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
verticalAlignment = Alignment.Bottom) {
AsyncImage(
model = user.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${user?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(100.dp)
@@ -143,12 +150,21 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
MessageButton(user, navController)
if (accountUser == user && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(user)
if (accountUser == user) {
EditButton(account)
} else {
if (accountUser.isFollowing(user)) {
if (account?.isAcceptable(user) == false) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
@@ -265,6 +281,25 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
) {
Text(text = "nsec", color = Color.White)
}
}
@Composable
private fun NPubCopyButton(
user: User
@@ -356,4 +391,18 @@ fun FollowButton(onClick: () -> Unit) {
}
}
@Composable
fun ShowUserButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Unblock", color = Color.White)
}
}
@@ -25,6 +25,7 @@ import androidx.compose.material.TextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -39,17 +40,22 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -78,12 +84,20 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val onlineSearch = NostrSearchEventOrUserDataSource
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.value.text.isNotBlank()
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
}
}
//LAST ROW
Row(
modifier = Modifier
@@ -96,6 +110,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
value = searchValue.value,
onValueChange = {
searchValue.value = it
if (it.text.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it.text)
searchResults.value = LocalCache.findUsersStartingWith(it.text)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
@@ -128,6 +146,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
onlineSearch.clear()
}
) {
Icon(
@@ -148,15 +168,14 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
bottom = 10.dp
)
) {
itemsIndexed(searchResults.value, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item) {
navController.navigate("User/${item.pubkeyHex}")
}
itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
itemsIndexed(searchResultsChannels.value, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item ->
ChannelName(
channelPicture = item.profilePicture(),
channelPicturePlaceholder = null,
channelTitle = {
Text(
"${item.info.name}",
@@ -168,7 +187,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
onClick = { navController.navigate("Channel/${item.idHex}") })
}
itemsIndexed(searchResultsNotes.value, key = { _, item -> item.idHex }) { index, item ->
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n"+item.idHex }) { index, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
@@ -198,6 +217,7 @@ fun UserLine(
AsyncImage(
model = user.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
@@ -216,7 +236,9 @@ fun UserLine(
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 KiB

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

@@ -0,0 +1 @@
<p><i>Amethyst</i> brings the best social network to your Android phone. Just insert your Nostr private key and start posting.</p><p><a href='https://github.com/nostr-protocol/nostr' target='_blank' rel='nofollow noopener'>Nostr</a> is an open protocol that is able to create a censorship-resistant global "social" network, transferring notes and other stuff using relays. It doesn't rely on any trusted central server, hence it is resilient; it is based on cryptographic keys and signatures, so it is tamperproof; it does not rely on P2P techniques, therefore it works.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

@@ -0,0 +1 @@
Nostr Client for Android