Compare commits

...
52 Commits
Author SHA1 Message Date
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
Vitor Pamplona b6c1273ecd v0.9.4 2023-01-21 00:55:25 -03:00
Vitor Pamplona 07f9c6e8c3 keeping other classes to make sure deserialization works. 2023-01-21 00:54:39 -03:00
Vitor Pamplona ea53a15105 Adding a Basic Privacy Policy 2023-01-20 23:56:54 -03:00
Vitor Pamplona 716a150fc3 Changing my mind... keeping the dot in time ago 2023-01-20 23:33:43 -03:00
Vitor Pamplona df3ae43ce7 Adjusting the message screen to correctly display the time aligned to the right. 2023-01-20 23:31:44 -03:00
Vitor Pamplona 09bbb59c61 v0.9.3 2023-01-20 23:05:56 -03:00
Vitor Pamplona 0f731117d4 Seems to solve constant crashing when reconnecting after a failure related to this issue: https://github.com/square/okhttp/issues/7381 2023-01-20 23:01:53 -03:00
Vitor Pamplona 115c1ba082 Edit Profile 2023-01-20 23:00:30 -03:00
Vitor Pamplona 135b24df6c Fixing NPE when socket disconnects after the initial check if == null 2023-01-20 18:57:50 -03:00
Vitor PamplonaandGitHub 207f03bb06 Merge pull request #6 from KotlinGeekDev/fix-navigation-issues
Fix navigation.
2023-01-20 16:48:25 -05:00
KotlinGeekDev e78c7aba85 Fix navigation. 2023-01-20 22:31:19 +01:00
Vitor PamplonaandGitHub 41b82ec749 Merge pull request #3 from KotlinGeekDev/reduce-apk-size
Make changes to reduce the release APK/app bundle size.
2023-01-20 14:44:58 -05:00
KotlinGeekDev b915956f57 Make changes to reduce the release APK/app bundle size. 2023-01-20 20:26:46 +01:00
59 changed files with 1680 additions and 369 deletions
+11
View File
@@ -0,0 +1,11 @@
# Amethyst Privacy Policy
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. 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
+6 -6
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 13
versionName "0.9.2"
versionCode 20
versionName "0.10.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,7 +22,7 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
@@ -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'
+7 -1
View File
@@ -18,4 +18,10 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
@@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
this.account = account
start()
}
fun start() {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect()
}
}
fun pause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
}
}
@@ -8,12 +8,16 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Date
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -38,6 +42,40 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
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
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
Client.send(event)
LocalCache.consume(event)
}
}
fun reactTo(note: Note) {
if (!isWriteable()) return
@@ -72,12 +110,19 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
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)
@@ -87,9 +132,12 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
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)
}
@@ -215,12 +263,28 @@ 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()
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(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
@@ -80,6 +82,7 @@ object LocalCache {
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -109,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
@@ -139,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
)
user.lastestContactList = event
}
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()
}
refreshObservers()
user.latestContactList = event
}
}
fun consume(event: PrivateDmEvent) {
@@ -200,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
@@ -230,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") {
@@ -256,12 +270,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}")
@@ -277,12 +291,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) {
@@ -297,7 +311,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)
@@ -306,10 +325,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
@@ -89,11 +89,15 @@ class Note(val idHex: String) {
}
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 +110,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()
@@ -1,10 +1,9 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
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
@@ -14,6 +13,8 @@ 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) {
val pubkeyHex = pubkey.toHexKey()
@@ -24,12 +25,16 @@ class User(val pubkey: ByteArray) {
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var lastestContactList: ContactListEvent? = null
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
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>>()
@@ -50,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)
@@ -63,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
@@ -77,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
}
@@ -87,7 +106,7 @@ class User(val pubkey: ByteArray) {
follows - newFollows
}
toBeAdded.forEach {
follow(it)
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
@@ -96,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
@@ -109,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.
@@ -119,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
)
}
@@ -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 {
@@ -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") {
@@ -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() {
@@ -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,15 +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)
}
@@ -107,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)
}
}
}
}
@@ -126,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) }
@@ -15,6 +15,8 @@ import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -44,7 +46,9 @@ class MainActivity : ComponentActivity() {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}.build()
}
.respectCacheHeaders(false)
.build()
}
setContent {
@@ -66,24 +70,13 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
Client.connect()
ServiceManager.start()
}
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
ServiceManager.pause()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
super.onPause()
}
}
@@ -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 }
@@ -0,0 +1,211 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
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 com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
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()
},
postViewModel.userName.value.isNotBlank()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
maxLines = 10
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}
@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import java.io.ByteArrayInputStream
import java.io.StringWriter
class NewUserMetadataViewModel: ViewModel() {
private lateinit var account: Account
val userName = mutableStateOf("")
val displayName = mutableStateOf("")
val about = mutableStateOf("")
val picture = mutableStateOf("")
val banner = mutableStateOf("")
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
fun load(account: Account) {
this.account = account
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info.about ?: ""
picture.value = it.info.picture ?: ""
banner.value = it.info.banner ?: ""
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
) as ObjectNode
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value)
currentJson.put("username", userName.value)
currentJson.put("display_name", displayName.value)
currentJson.put("displayName", displayName.value)
currentJson.put("picture", picture.value)
currentJson.put("banner", banner.value)
currentJson.put("website", website.value)
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud06", lnAddress.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString())
clear()
}
fun clear() {
userName.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
}
}
@@ -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)
@@ -47,10 +47,25 @@ fun AppBottomBar(navController: NavHostController) {
selected = currentRoute == item.route,
onClick = {
if (currentRoute != item.route) {
navController.navigate(item.route)
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route)
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { 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)
@@ -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
@@ -54,18 +56,24 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
} else {
note.event?.content
}
channel?.let {
channel?.let { channel ->
ChannelName(
channelPicture = it.profilePicture(),
channelPicture = channel.profilePicture(),
channelPicturePlaceholder = null,
channelTitle = {
Text(
"${it.info.name}",
fontWeight = FontWeight.Bold
"${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,
onClick = { navController.navigate("Channel/${it.idHex}") })
onClick = { navController.navigate("Channel/${channel.idHex}") })
}
} else {
@@ -85,13 +93,14 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
}
userToComposeOn?.let {
userToComposeOn?.let { user ->
ChannelName(
channelPicture = it.profilePicture(),
channelTitle = { UsernameDisplay(it) },
channelPicture = user.profilePicture(),
channelPicturePlaceholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = note.event?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
}
}
@@ -100,7 +109,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
@Composable
fun ChannelName(
channelPicture: String,
channelTitle: @Composable () -> Unit,
channelPicturePlaceholder: Painter?,
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?,
channelLastContent: String?,
onClick: () -> Unit
@@ -112,6 +122,7 @@ fun ChannelName(
AsyncImage(
model = channelPicture,
placeholder = channelPicturePlaceholder,
contentDescription = "Profile Image",
modifier = Modifier
.width(55.dp)
@@ -125,7 +136,7 @@ fun ChannelName(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
channelTitle()
channelTitle(Modifier.weight(1f))
channelLastTime?.let {
Text(
@@ -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)
@@ -32,6 +32,7 @@ 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
@@ -87,6 +88,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 +105,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)
@@ -1,24 +1,40 @@
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.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
@@ -35,104 +51,117 @@ 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()) 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()) 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)
)
}
}
}
@@ -16,9 +16,11 @@ 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.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.User
import com.vitorpamplona.amethyst.ui.screen.FollowButton
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
@@ -46,6 +48,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,8 +61,10 @@ 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
)
}
@@ -1,12 +1,13 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
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,16 +15,24 @@ import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.MainActivity
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() {
class AccountStateViewModel(
private val encryptedPreferences: EncryptedSharedPreferences
): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
@@ -66,19 +75,9 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
else
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
NostrAccountDataSource.account = account
NostrHomeDataSource.account = account
NostrNotificationDataSource.account = account
NostrChatroomListDataSource.account = account
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrSingleEventDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
viewModelScope.launch(Dispatchers.IO) {
ServiceManager.start(account)
}
}
fun logOff() {
@@ -91,6 +90,7 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
remove("following_channels")
}.apply()
}
@@ -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)
@@ -33,7 +33,7 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
val feedContent = _feedContent.asStateFlow()
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshSuspended()
}
@@ -64,16 +64,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
}
}
}
@@ -40,6 +40,7 @@ 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.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
@@ -135,6 +136,7 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
AsyncImage(
model = channel?.profilePicture(),
placeholder = null,
contentDescription = "Profile Image",
modifier = Modifier
.width(35.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)
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -30,7 +30,10 @@ 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.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
@@ -46,26 +49,25 @@ 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.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
data class TabRowItem(
val title: String,
val screen: @Composable () -> Unit,
)
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
@@ -75,7 +77,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (userId != null && account != null && accountUser != null) {
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
@@ -105,14 +107,18 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth().height(125.dp)
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
@@ -125,6 +131,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)
@@ -141,9 +148,9 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
NPubCopyButton(user)
if (accountUser == user) {
EditButton()
EditButton(account)
} else {
if (accountUser.isFollowing(user) == true) {
if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else {
FollowButton { account.follow(user) }
@@ -300,10 +307,17 @@ private fun MessageButton(user: User, navController: NavController) {
}
@Composable
private fun EditButton() {
private fun EditButton(account: Account) {
var wantsToEdit by remember {
mutableStateOf(false)
}
if (wantsToEdit)
NewUserMetadataView({ wantsToEdit = false }, account)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {},
onClick = { wantsToEdit = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
@@ -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
@@ -43,11 +44,14 @@ 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.UsernameDisplay
@@ -78,12 +82,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 +108,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 +144,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
onlineSearch.clear()
}
) {
Icon(
@@ -148,15 +166,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
bottom = 10.dp
)
) {
itemsIndexed(searchResults.value, key = { _, item -> item.pubkeyHex }) { index, item ->
itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item ->
UserLine(item) {
navController.navigate("User/${item.pubkeyHex}")
}
}
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)
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

+2 -1
View File
@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
android.enableR8.fullMode=true