Compare commits

..
64 Commits
Author SHA1 Message Date
Vitor Pamplona a153f577c9 0.17.2: Quote Note Popup And Rendering 2023-02-20 18:10:40 -05:00
Vitor Pamplona 9213394731 Rendering Quoted Noted 2023-02-20 18:09:57 -05:00
Vitor Pamplona a24e6391dc Maxing out post size when on Twitter-like interfaces. 2023-02-20 17:36:20 -05:00
Vitor Pamplona dbcd5ed7fe Quote Note support 2023-02-20 17:28:58 -05:00
Vitor Pamplona 1f2e120851 Trying to hunt down missing notifications. 2023-02-20 17:27:40 -05:00
Vitor Pamplona 88432d4ab6 Notification Note background is now clickable. 2023-02-20 16:39:02 -05:00
Vitor PamplonaandGitHub 7e2b8397e7 Merge pull request #142 from terryschmidt/leak_canary
add leak canary to detect memory leaks in debug builds
2023-02-20 15:19:20 -05:00
Vitor PamplonaandGitHub 3b990c5f2d Merge pull request #141 from terryschmidt/update_gradle
update gradle
2023-02-20 15:18:18 -05:00
Vitor Pamplona 8b70a11754 Merge remote-tracking branch 'origin/main' 2023-02-20 15:17:50 -05:00
Vitor PamplonaandGitHub f36906f6c0 Merge pull request #140 from terryschmidt/fix_app_name_error
fix redundant android:label in AndroidManifest.xml
2023-02-20 15:17:11 -05:00
Vitor Pamplona 19cc0d1ce8 Shows comma in some keyboards 2023-02-20 14:58:05 -05:00
Vitor Pamplona 1f953cb728 Fixes changing editing amounts on recompose. 2023-02-20 14:57:53 -05:00
Vitor Pamplona 70c20fbb6d Resets mentions to avoid marking users in new posts from an old reply. 2023-02-20 14:41:50 -05:00
Vitor Pamplona 8db743c1e3 Fixes the "Show Anyway" button not working in the first element of the Thread view 2023-02-20 14:41:32 -05:00
Vitor Pamplona 51061250e2 0v.17.1 2023-02-20 13:33:15 -05:00
Vitor Pamplona ddd35c5119 Custom Zap Amounts. 2023-02-20 13:23:44 -05:00
Vitor Pamplona dcc84f4572 Sets Search to SingleLine only 2023-02-20 13:23:24 -05:00
Vitor Pamplona 0236837856 Displays lnurl setups as lightning addresses in the Profile page. 2023-02-20 12:09:25 -05:00
Vitor Pamplona 892214ec27 Pays zaps through LNURLs / lud06 fields 2023-02-20 12:09:00 -05:00
Terry Schmidt dd404efc7d fix redundant android:label warning 2023-02-20 11:44:56 -05:00
Terry Schmidt 2bd3792b8f add leak canary to detect memory leaks in debug builds 2023-02-20 01:34:01 -05:00
Terry Schmidt ea18b5d5cc update gradle 2023-02-20 01:30:07 -05:00
Vitor Pamplona 2eae1ad434 v0.17.0 2023-02-19 16:40:46 -05:00
Vitor Pamplona 03dd09e957 Adds a second list of filtered Users that were automatically added and are not saved with the person's key 2023-02-19 16:38:59 -05:00
Vitor Pamplona f34668b90d Moves processing of new events from IO to Default 2023-02-19 15:38:08 -05:00
Vitor Pamplona 504f9cf978 Removes subscriptions from the list once closed. 2023-02-19 15:37:44 -05:00
Vitor Pamplona 182ac5de07 Changes the follower calculation to only store when important to the user. 2023-02-19 15:37:26 -05:00
Vitor Pamplona 6063bd789d Adds anti-spam filters 2023-02-19 15:36:43 -05:00
Vitor Pamplona c610d130f8 Pruning Messages from Hidden Users. 2023-02-19 15:36:00 -05:00
Vitor Pamplona 23a5e9e66b Memory Pruning: Only keeping 1000 messages per channel 2023-02-19 12:40:19 -05:00
Vitor Pamplona d57fca3549 Splits profile between new threads and replies 2023-02-19 11:34:45 -05:00
Vitor Pamplona 8bc5aab880 Uses 1/3 of the memory per not-fully loaded user/note
BugFix for invalid Hexes in mentions, contact lists, etc.
2023-02-19 11:22:01 -05:00
Vitor Pamplona 71b86ea69c Logo in transparent PNG 2023-02-18 19:21:02 -05:00
Vitor Pamplona 71dad0c69e Fixes missing elements in the previous commit 2023-02-18 19:20:39 -05:00
Vitor Pamplona b8cb6295f4 Goes back to considering the full list of results when assessing changes to the visible items. 2023-02-18 19:15:24 -05:00
Vitor Pamplona 666972fd62 Displays the amount and list of reports in the Profile page. 2023-02-18 19:14:52 -05:00
Vitor Pamplona 0914a7b68c Adds a lastReportedTime field (to be used with Since in the websocket filter) for Users with too many reports. 2023-02-18 19:14:27 -05:00
Vitor Pamplona de2ec503da Keeps Note level in a thread cached for ordering notes. 2023-02-18 19:13:43 -05:00
Vitor Pamplona 53a14d3a77 Optimizes the UserProfile datasource to remove the filter when the user leaves the profile screen. 2023-02-18 19:12:21 -05:00
Vitor Pamplona dfe95c28ea Displays debug information on how many notes and users where fully loaded to the phone as a % of total references (pubkeys, ids) stored 2023-02-18 19:11:10 -05:00
Vitor Pamplona bf9f4202d4 Display follows in the reverse order (latest follows first) 2023-02-18 19:10:31 -05:00
Vitor Pamplona 54cdb5ae68 Removes Cited Posts from the replies (Fixes Quoted Notes) 2023-02-18 19:09:49 -05:00
Vitor Pamplona 6ff474376f Stops User Profile Websocket when pausing the app. 2023-02-18 19:09:16 -05:00
Vitor Pamplona 138b22348c Makes sure Websocket dispatchers are using IO threads 2023-02-18 19:08:52 -05:00
Vitor Pamplona 2ac5174b78 Fixes more places where Android's coroutine cancellations might affect a mutex. 2023-02-18 19:07:58 -05:00
Vitor Pamplona 89f714b1ad Refactors thread assembler to reduce number of lists used. 2023-02-18 19:07:16 -05:00
Vitor Pamplona d2c39b022b Closes subscriptions regardless with they are active or not. 2023-02-18 19:06:39 -05:00
Vitor Pamplona ccd6beee8e Reduces the amount of Reports downloaded when observing single users. 2023-02-18 19:06:05 -05:00
Vitor Pamplona b7c72a282f Allows Reported messages to appear (with warning) in notifications. 2023-02-18 19:05:33 -05:00
Vitor Pamplona 0d1bf72708 Fixes Notification for messaging with yourself. 2023-02-18 19:04:56 -05:00
Vitor Pamplona 34bdc2b103 Fixes Mutex when Android cancels coroutines. 2023-02-18 19:04:03 -05:00
Vitor Pamplona cc8866d240 Fixing Debug messages. 2023-02-18 19:02:47 -05:00
Vitor Pamplona b60d7ff857 Trimming User Metadata fields to avoid crashes when using them 2023-02-18 14:05:46 -05:00
Vitor Pamplona 4f53a74004 Massive Refactoring:
1. Split between Relay Filters and Screen Filters.
2. Moving Notification dots to background threads.
3. Loading new posts on ThreadView on the fly.
2023-02-18 13:06:53 -05:00
Vitor Pamplona f0e09197ff Avoids starting connections with default relays when resuming the app. 2023-02-17 12:27:36 -05:00
Vitor Pamplona 4afcf48392 v0.16.2 2023-02-16 19:43:33 -05:00
Vitor Pamplona 943d4525be Removing favicons from proxy 2023-02-16 19:41:50 -05:00
Vitor Pamplona de302eb966 Improving error count for relays to also include wrong URLs 2023-02-16 19:41:31 -05:00
Vitor Pamplona 85adc722ae v0.16.1 2023-02-16 19:04:26 -05:00
Vitor Pamplona c8c48cca32 BugFix: User's relays not coming back after a pause of the app if they have not setup relays through the app yet. 2023-02-16 19:03:11 -05:00
Vitor Pamplona 3f68b42acd Moving Async Image Proxy to components. 2023-02-16 18:25:55 -05:00
Vitor Pamplona da5c61038d Fixes https://github.com/vitorpamplona/amethyst/issues/129 2023-02-16 08:17:41 -05:00
Vitor Pamplona e8949f08ae Asks reports when each user/event is seen instead. 2023-02-15 17:20:39 -05:00
Vitor Pamplona 068b5faf7d Remove logs 2023-02-15 17:19:55 -05:00
98 changed files with 2355 additions and 1141 deletions
+5 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 53
versionName "0.16.0"
versionCode 58
versionName "0.17.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -136,6 +136,9 @@ dependencies {
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
@@ -5,6 +5,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
@@ -29,7 +30,8 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
@@ -63,7 +65,8 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
-1
View File
@@ -24,7 +24,6 @@
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:label="@string/app_name"
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
@@ -23,6 +23,7 @@ class LocalPreferences(context: Context) {
remove("relays")
remove("dontTranslateFrom")
remove("translateTo")
remove("zapAmounts")
}.apply()
}
@@ -35,6 +36,7 @@ class LocalPreferences(context: Context) {
account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) }
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
}.apply()
}
@@ -52,6 +54,11 @@ class LocalPreferences(context: Context) {
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
val zapAmountChoices = gson.fromJson(
getString("zapAmounts", "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -59,7 +66,8 @@ class LocalPreferences(context: Context) {
hiddenUsers,
localRelays,
dontTranslateFrom,
translateTo
translateTo,
zapAmountChoices
)
} else {
return null
@@ -1,20 +1,18 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.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.NostrSingleChannelDataSource
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 {
@@ -29,30 +27,23 @@ object ServiceManager {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.convertLocalRelays())
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
NostrChannelDataSource.account = myAccount
NostrUserProfileDataSource.account = myAccount
NostrUserProfileFollowsDataSource.account = myAccount
NostrUserProfileFollowersDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
//NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
//NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
@@ -65,17 +56,25 @@ object ServiceManager {
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
Client.disconnect()
}
fun cleanUp() {
LocalCache.cleanObservers()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
//LocalCache.pruneNonFollows(it)
}
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
@@ -20,11 +21,19 @@ class LightningAddressResolver {
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size != 2) {
return null
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
if (lnaddress.toLowerCase().startsWith("lnurl")) {
return try {
String(Bech32.decodeBytes(lnaddress, false).second)
} catch (e: Exception) {
null
}
}
return null
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
@@ -22,8 +22,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
@@ -54,8 +56,11 @@ class Account(
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L)
) {
var transientHiddenUsers: Set<String> = setOf()
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
@@ -65,7 +70,7 @@ class Account(
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
@@ -274,8 +279,8 @@ class Account(
val user = LocalCache.users[toUser] ?: return
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey,
publishedRecipientPubKey = user.pubkey,
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
@@ -319,6 +324,12 @@ class Account(
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex
invalidateData(live)
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
invalidateData(live)
}
@@ -394,12 +405,31 @@ class Account(
}.toTypedArray()
}
fun reconnectIfRelaysHaveChanged() {
val newRelaySet = activeRelays() ?: convertLocalRelays()
if (!Client.isSameRelaySetConfig(newRelaySet)) {
Client.disconnect()
Client.connect(newRelaySet)
RelayPool.requestAndWatch()
}
}
init {
userProfile().liveRelays.observeForever {
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
Client.disconnect()
Client.connect(activeRelays() ?: convertLocalRelays())
RelayPool.requestAndWatch()
reconnectIfRelaysHaveChanged()
}
}
LocalCache.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
LocalCache.spamMessages.snapshot().values.forEach {
if (it !in hiddenUsers) {
val userToBlock = LocalCache.getOrCreateUser(it)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
transientHiddenUsers = transientHiddenUsers + it
}
}
}
}
}
}
@@ -417,9 +447,14 @@ class Account(
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting.set(false)
try {
delay(100)
live.refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -1,15 +1,15 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.concurrent.ConcurrentHashMap
class Channel(val id: ByteArray) {
val idHex = id.toHexKey()
val idDisplayHex = id.toShortenHex()
class Channel(val idHex: String) {
var creator: User? = null
var info = ChannelCreateEvent.ChannelData(null, null, null)
@@ -17,8 +17,12 @@ class Channel(val id: ByteArray) {
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayHex
return info.name ?: idDisplayNote()
}
@Synchronized
@@ -50,6 +54,23 @@ class Channel(val id: ByteArray) {
private fun refreshObservers() {
live.refresh()
}
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.event?.createdAt }
.reversed()
.take(1000)
.toSet()
val toBeRemoved = notes.values.filter { it in important }.toSet()
toBeRemoved.forEach {
notes.remove(it.idHex)
}
return toBeRemoved
}
}
@@ -60,12 +81,12 @@ class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState
override fun onActive() {
super.onActive()
NostrSingleEventDataSource.add(channel.idHex)
NostrSingleChannelDataSource.add(channel.idHex)
}
override fun onInactive() {
super.onInactive()
NostrSingleEventDataSource.remove(channel.idHex)
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
@@ -41,6 +41,8 @@ fun decodePublicKey(key: String): ByteArray {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else if (key.startsWith("note")) {
key.bechToBytes()
} else { //if (pattern.matcher(key).matches()) {
//} else {
Hex.decode(key)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -15,19 +16,23 @@ import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import fr.acinq.secp256k1.Hex
import java.io.ByteArrayInputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -36,7 +41,6 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import nostr.postr.toNpub
object LocalCache {
@@ -48,6 +52,19 @@ object LocalCache {
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, String>(1000)
fun checkGetOrCreateUser(key: HexKey): User? {
return try {
val checkHex = Hex.decode(key) // Checks if this is a valid Hex
getOrCreateUser(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
null
}
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
return users[key] ?: run {
@@ -69,7 +86,7 @@ object LocalCache {
@Synchronized
fun getOrCreateChannel(key: String): Channel {
return channels[key] ?: run {
val answer = Channel(key.toByteArray())
val answer = Channel(key)
channels.put(key, answer)
answer
}
@@ -79,18 +96,19 @@ object LocalCache {
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldUser.updatedMetadataAt) {
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
metadataParser.readValue(
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
return
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
oldUser.updateUserInfo(newUser, event)
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -102,7 +120,30 @@ object LocalCache {
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
@Synchronized
fun isRepeatedMessageSpam(event: Event): Boolean {
val idHex = event.id.toHexKey()
// if already processed, return
if (notes[idHex] != null) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if (event.content.length > 50 && ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null)) {
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, event.pubKey.toHexKey())
liveSpam.invalidateData()
}
return true
}
recentMessages.put(hash, idHex)
return false
}
fun consume(event: TextNoteEvent, relay: Relay? = null) {
if (isRepeatedMessageSpam(event)) return
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
@@ -115,8 +156,8 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos.map { getOrCreateNote(it) }
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
@@ -141,6 +182,24 @@ object LocalCache {
refreshObservers()
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return event.replyTos.filter { it !in citations }
}
fun consume(event: RecommendRelayEvent) {
//Log.d("RR", event.toJson())
}
@@ -149,7 +208,6 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
try {
@@ -165,21 +223,30 @@ object LocalCache {
event.createdAt
)
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
// Saves relay list only if it's a user that is currently been seen
if (user.liveSet?.isInUse() == true) {
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)
user.updateRelays(relays)
}
} catch (e: Exception) {
e.printStackTrace()
}
} catch (e: Exception) {
e.printStackTrace()
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
}
user.latestContactList = event
Log.d(
"CL",
"AAA ${user.toBestDisplayName()} ${event.follows.size}"
)
}
}
@@ -200,7 +267,7 @@ object LocalCache {
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -225,7 +292,7 @@ object LocalCache {
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -256,7 +323,7 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -301,7 +368,7 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.map { getOrCreateUser(it) }
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -363,6 +430,7 @@ object LocalCache {
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
if (isRepeatedMessageSpam(event)) return
val channel = getOrCreateChannel(event.channel)
@@ -379,7 +447,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.map { getOrCreateUser(it) }
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
@@ -420,7 +488,7 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -456,7 +524,7 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.map { getOrCreateUser(it) }
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -481,9 +549,9 @@ object LocalCache {
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter {
it.info.anyNameStartsWith(username)
(it.anyNameStartsWith(username))
|| it.pubkeyHex.startsWith(username, true)
|| it.pubkey.toNpub().startsWith(username, true)
|| it.pubkeyNpub().startsWith(username, true)
}
}
@@ -492,20 +560,94 @@ object LocalCache {
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
|| it.idNote().startsWith(text, true)
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
return channels.values.filter {
it.anyNameStartsWith(text)
it.anyNameStartsWith(text)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
|| it.idNote().startsWith(text, true)
}
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
}
users.forEach {
it.value.clearLive()
}
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach {
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
users[it]?.notes ?: emptySet()
}.flatten()
account.hiddenUsers.forEach {
users[it]?.clearNotes()
}
toBeRemoved.forEach {
notes.remove(it.idHex)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val liveSpam: LocalCacheLiveData = LocalCacheLiveData(this)
private fun refreshObservers() {
live.invalidateData()
@@ -525,9 +667,14 @@ class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(Local
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -11,7 +10,6 @@ import fr.acinq.secp256k1.Hex
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -21,18 +19,13 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import java.math.BigDecimal
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
import nostr.postr.toNpub
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayNote = id.toNote().toShortenHex()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
@@ -59,13 +52,17 @@ class Note(val idHex: String) {
var lastReactionsDownloadTime: Long? = null
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
live.invalidateData()
liveSet?.metadata?.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -73,56 +70,61 @@ class Note(val idHex: String) {
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
fun replyLevelSignature(): String {
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
return replyTo
.map { it.replyLevelSignature() }
.map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
fun replyLevel(): Int {
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return 0
}
return replyTo.maxOf {
it.replyLevel()
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveReplies.invalidateData()
liveSet?.replies?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveBoosts.invalidateData()
liveSet?.boosts?.invalidateData()
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
liveSet?.zaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
liveReactions.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
@@ -131,17 +133,17 @@ class Note(val idHex: String) {
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveReports.invalidateData()
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveReports.invalidateData()
liveSet?.reports?.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveRelays.invalidateData()
liveSet?.relays?.invalidateData()
}
}
@@ -184,7 +186,7 @@ class Note(val idHex: String) {
val dayAgo = Date().time / 1000 - 24*60*60
return reports.isNotEmpty() ||
(author?.reports?.values?.filter {
it.firstOrNull { it.event?.createdAt ?: 0 > dayAgo } != null
it.firstOrNull { ( it.event?.createdAt ?: 0 ) > dayAgo } != null
}?.isNotEmpty() ?: false)
}
@@ -195,7 +197,9 @@ class Note(val idHex: String) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(LocalCache.getOrCreateUser(tag[1]))
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
@@ -228,15 +232,42 @@ class Note(val idHex: String) {
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
var liveSet: NoteLiveSet? = null
val liveReactions: NoteLiveData = NoteLiveData(this)
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
val liveRelays: NoteLiveData = NoteLiveData(this)
val liveZaps: NoteLiveData = NoteLiveData(this)
fun live(): NoteLiveSet {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
liveSet = null
}
}
}
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
val reports: NoteLiveData = NoteLiveData(u)
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
fun isInUse(): Boolean {
return reactions.hasObservers()
|| boosts.hasObservers()
|| replies.hasObservers()
|| reports.hasObservers()
|| relays.hasObservers()
|| zaps.hasObservers()
}
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.model
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)
searchRoot(it, testedNotes)
else
null
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
val (result, elapsed) = measureTimedValue {
val note = LocalCache.getOrCreateNote(noteId)
if (note.event != null) {
val thread = mutableSetOf<Note>()
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
thread.toSet()
} else {
setOf(note)
}
}
println("Model Refresh: Thread loaded in ${elapsed}")
return result
}
fun loadDown(note: Note, thread: MutableSet<Note>) {
if (note !in thread) {
thread.add(note)
note.replies.forEach {
loadDown(it, thread)
}
}
}
}
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import android.util.Patterns
import androidx.compose.ui.text.toLowerCase
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -9,26 +11,26 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.toNpub
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class User(val pubkeyHex: String) {
val pubkey = Hex.decode(pubkeyHex)
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
var info: UserMetadata? = null
var info = UserMetadata()
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
var follows = setOf<User>()
private set
@@ -42,6 +44,7 @@ class User(val pubkeyHex: String) {
var reports = mapOf<User, Set<Note>>()
private set
var latestReportTime: Long = 0
var zaps = mapOf<Note, Note?>()
private set
@@ -52,67 +55,67 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
data class Chatroom(var roomMessages: Set<Note>)
var privateChatrooms = mapOf<User, Chatroom>()
private set
var latestReportRequestEOSE: Long? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
fun bestUsername(): String? {
return info.name?.ifBlank { null } ?: info.username?.ifBlank { null }
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
fun follow(user: User, followedAt: Long) {
follows = follows + user
user.followers = user.followers + this
liveFollows.invalidateData()
user.liveFollows.invalidateData()
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveFollows.invalidateData()
user.liveFollows.invalidateData()
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
if (this !in it.followers) {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveFollows.invalidateData()
it.liveSet?.follows?.invalidateData()
}
}
liveFollows.invalidateData()
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers) {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveFollows.invalidateData()
it.liveSet?.follows?.invalidateData()
}
}
liveFollows.invalidateData()
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
@@ -129,25 +132,34 @@ class User(val pubkeyHex: String) {
}
}
fun clearNotes() {
notes = setOf<Note>()
}
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveReports.invalidateData()
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveReports.invalidateData()
liveSet?.reports?.invalidateData()
}
val reportTime = note.event?.createdAt ?: 0
if (reportTime > latestReportTime) {
latestReportTime = reportTime
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
liveSet?.zaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveZaps.invalidateData()
liveSet?.zaps?.invalidateData()
}
}
@@ -186,16 +198,10 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
liveMessages.invalidateData()
liveSet?.messages?.invalidateData()
}
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
fun addRelay(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
@@ -207,7 +213,7 @@ class User(val pubkeyHex: String) {
here.counter++
}
liveRelayInfo.invalidateData()
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
@@ -221,17 +227,33 @@ class User(val pubkeyHex: String) {
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
liveRelays.invalidateData()
}
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
updatedMetadataAt = updateAt
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
liveMetadata.invalidateData()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.toLowerCase()?.startsWith("lnurl") == true) {
try {
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
val matcher = lnurlpPattern.matcher(url)
while (matcher.find()) {
val domain = matcher.group(2)
val username = matcher.group(3)
info?.lud16 = "$username@$domain"
}
} catch (t: Throwable) {
// Doesn't create errors.
}
}
liveSet?.metadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -250,16 +272,55 @@ class User(val pubkeyHex: String) {
} != null
}
// UI Observers line up here.
val liveFollows: UserLiveData = UserLiveData(this)
val liveReports: UserLiveData = UserLiveData(this)
val liveMessages: UserLiveData = UserLiveData(this)
val liveRelays: UserLiveData = UserLiveData(this)
val liveRelayInfo: UserLiveData = UserLiveData(this)
val liveMetadata: UserLiveData = UserLiveData(this)
val liveZaps: UserLiveData = UserLiveData(this)
fun anyNameStartsWith(username: String): Boolean {
return info?.anyNameStartsWith(username) ?: false
}
var liveSet: UserLiveSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
liveSet = null
}
}
}
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
val relayInfo: UserLiveData = UserLiveData(u)
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
fun isInUse(): Boolean {
return follows.hasObservers()
|| reports.hasObservers()
|| messages.hasObservers()
|| relays.hasObservers()
|| relayInfo.hasObservers()
|| metadata.hasObservers()
|| zaps.hasObservers()
}
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
data class Chatroom(var roomMessages: Set<Note>)
class UserMetadata {
var name: String? = null
var username: String? = null
@@ -279,6 +340,9 @@ class UserMetadata {
var main_relay: String? = null
var twitter: String? = null
var updatedMetadataAt: Long = 0;
var latestMetadata: MetadataEvent? = null
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
@@ -298,9 +362,14 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -310,13 +379,14 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user.pubkeyHex)
NostrSingleUserDataSource.add(user)
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user.pubkeyHex)
NostrSingleUserDataSource.remove(user)
}
}
class UserState(val user: User)
@@ -15,7 +15,7 @@ import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
object NostrAccountDataSource: NostrDataSource("AccountData") {
lateinit var account: Account
fun createAccountContactListFilter(): TypedFilter {
@@ -63,15 +63,6 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
val accountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -8,9 +8,8 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.getOrCreateChannel(channelId)
@@ -33,11 +32,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
val messagesChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
@@ -9,13 +9,14 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.users[userId]
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
@@ -40,7 +41,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -54,13 +55,6 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
val inandoutChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().privateChatrooms[withUser] ?: return emptyList()
return messages.roomMessages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
@@ -10,7 +10,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = TypedFilter(
@@ -38,7 +38,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
)
fun createMyChannelsFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
@@ -48,7 +48,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
@@ -73,22 +73,6 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
val chatroomListChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
@@ -33,9 +33,8 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter:Int)
private var eventCounter = mapOf<String, Counter>()
@@ -132,30 +131,8 @@ abstract class NostrDataSource<T>(val debugName: String) {
open fun stop() {
subscriptions.values.forEach { channel ->
if (channel.typedFilters != null) {// if it is active, close
Client.close(channel.id)
channel.typedFilters = null
}
}
}
fun loadTop(): List<T> {
val returningList = feed().take(1000)
// prepare previews
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
loadPreviews(returningList)
}
return returningList
}
fun loadPreviews(list: List<T>) {
list.forEach {
if (it is Note) {
UrlCachedPreviewer.preloadPreviewsFor(it)
}
Client.close(channel.id)
channel.typedFilters = null
}
}
@@ -231,5 +208,4 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
abstract fun updateChannelFilters()
abstract fun feed(): List<T>
}
@@ -9,7 +9,7 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
@@ -21,15 +21,6 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
@@ -16,7 +16,7 @@ import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
@@ -26,7 +26,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
override fun start() {
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().liveFollows.observeForever(cacheListener)
account.userProfile().live().follows.observeForever(cacheListener)
}
}
super.start()
@@ -36,7 +36,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
super.stop()
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().liveFollows.removeObserver(cacheListener)
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
}
@@ -45,7 +45,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
val follows = account.userProfile().follows
val followKeys = follows.map {
it.pubkey.toHex().substring(0, 6)
it.pubkeyHex.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -53,7 +53,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
kinds = listOf(TextNoteEvent.kind),
authors = followSet,
limit = 400
)
@@ -62,16 +62,6 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
val followAccountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
@@ -11,7 +11,7 @@ import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
@@ -39,10 +39,6 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
val searchChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList<Note>()
}
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
@@ -51,10 +51,6 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
val singleChannelChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList()
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
@@ -15,7 +15,7 @@ import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
@@ -87,14 +87,6 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
invalidateFilters()
}
override fun feed(): List<Note> {
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
@@ -8,18 +8,18 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
@@ -34,7 +34,8 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestReportTime
)
)
}
@@ -46,25 +47,20 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
invalidateFilters()
}
override fun feed(): List<User> {
return synchronized(usersToWatch) {
usersToWatch.map {
LocalCache.users[it]
}.filterNotNull()
}
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
}
fun add(userId: String) {
usersToWatch = usersToWatch.plus(userId)
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
fun remove(userId: String) {
usersToWatch = usersToWatch.minus(userId)
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
@@ -2,117 +2,48 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): TypedFilter? {
if (eventsToWatch.isEmpty()) {
return null
}
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to eventsToWatch.toList())
)
)
}
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val threadToLoad = eventToWatch ?: return null
val eventsToLoad = nodes
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
if (eventsToLoad.isEmpty()) {
return null
}
.map { it.idHex }
.toSet()
.ifEmpty { null } ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
return eventsToWatch.map {
LocalCache.getOrCreateNote(it)
}.sortedWith(order)
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)
searchRoot(it, testedNotes)
else
null
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
fun loadThread(noteId: String) {
val note = LocalCache.getOrCreateNote(noteId)
if (note.event != null) {
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
val threadRoot = searchRoot(note) ?: note
loadDown(threadRoot, thread, threadSet)
eventsToWatch = thread.map { it.idHex }.toSet()
} else {
eventsToWatch = setOf(noteId)
}
val loadEventsChannel = requestNewChannel(){
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
if (note !in threadSet) {
thread.add(note)
threadSet.add(note)
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
invalidateFilters()
}
}
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -12,77 +12,76 @@ import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
lateinit var account: Account
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.getOrCreateUser(userId)
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters()
}
fun createUserInfoFilter(): TypedFilter {
return TypedFilter(
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createUserPostsFilter(): TypedFilter {
return TypedFilter(
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
authors = listOf(it.pubkeyHex),
limit = 100
)
)
}
fun createUserReceivedZapsFilter(): TypedFilter {
return TypedFilter(
fun createUserReceivedZapsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
tags = mapOf("p" to listOf(it.pubkeyHex))
)
)
}
fun createFollowFilter(): TypedFilter {
return TypedFilter(
fun createFollowFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createFollowersFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
fun createFollowersFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
)
)
)
}
val userInfoChannel = requestNewChannel()
override fun feed(): List<Note> {
return user?.notes
?.filter { account.isAcceptable(it) }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
override fun updateChannelFilters() {
userInfoChannel.typedFilters = listOf(
userInfoChannel.typedFilters = listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
@@ -1,23 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
override fun feed(): List<User> {
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
@@ -16,10 +17,11 @@ class ChannelCreateEvent (
@Transient val channelInfo: ChannelData
init {
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
channelInfo = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null)
}
}
@@ -27,10 +29,15 @@ class ChannelCreateEvent (
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = if (channelInfo != null)
gson.toJson(channelInfo)
else
val content = try {
if (channelInfo != null)
gson.toJson(channelInfo)
else
""
} catch (t: Throwable) {
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
""
}
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = emptyList<List<String>>()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
@@ -20,21 +21,24 @@ class ChannelMetadataEvent (
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
}
channelInfo =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelCreateEvent.ChannelData(null, null, null)
}
}
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
val content = if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val content =
if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.events.Event
@@ -32,6 +33,20 @@ object Client: RelayPool.Listener {
this.relays = relays
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
if (relays.size != newRelayConfig.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url }
if (newRelayInfo == null) return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
}
return true
}
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
@@ -54,6 +69,7 @@ object Client: RelayPool.Listener {
fun close(subscriptionId: String) {
RelayPool.close(subscriptionId)
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
@@ -65,7 +81,7 @@ object Client: RelayPool.Listener {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
}
@@ -73,7 +89,7 @@ object Client: RelayPool.Listener {
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
@@ -81,7 +97,7 @@ object Client: RelayPool.Listener {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
}
@@ -89,7 +105,7 @@ object Client: RelayPool.Listener {
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch {
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
}
@@ -145,6 +145,8 @@ class Relay(
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
errorCounter++
isReady = false
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
@@ -202,6 +204,13 @@ class Relay(
socket?.send("""["CLOSE","$subscriptionId"]""")
}
fun isSameRelayConfig(other: Relay): Boolean {
return url == other.url
&& write == other.write
&& read == other.read
&& activeTypes == other.activeTypes
}
enum class Type {
// Websocket connected
CONNECT,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui
import android.content.ComponentCallbacks2
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
@@ -18,9 +19,11 @@ import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19
@@ -51,7 +54,7 @@ class MainActivity : ComponentActivity() {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}
} //.logger(DebugLogger())
.respectCacheHeaders(false)
.build()
}
@@ -75,7 +78,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Only starts after login
ServiceManager.start()
}
@@ -84,4 +87,14 @@ class MainActivity : ComponentActivity() {
super.onPause()
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
* @param level the memory-related event that was raised.
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
ServiceManager.cleanUp()
}
}
@@ -45,11 +45,9 @@ import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
val postViewModel: NewPostViewModel = viewModel()
postViewModel.load(account, baseReplyTo)
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
@@ -59,6 +57,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
val scroolState = rememberScrollState()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
delay(100)
focusRequester.requestFocus()
@@ -245,6 +244,25 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
}
}
@Composable
fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}
@Composable
fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
@@ -31,7 +31,7 @@ class NewPostViewModel: ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?) {
fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
@@ -45,29 +45,60 @@ class NewPostViewModel: ViewModel() {
}
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
}
this.account = account
}
fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int {
val replyToSize = replyTos?.size ?: 0
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
var myMentions = mentions
if (myMentions == null) {
mentions = listOf(user)
return replyToSize + 0 // position of the user
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
val index = myMentions.indexOf(user)
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
if (index >= 0) return replyToSize + index
myMentions = myMentions.plus(user)
mentions = myMentions
return replyToSize + myMentions.indexOf(user)
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// Moves @npub to mentions
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
addUserToMentions(user)
} else if (word.startsWith("@note") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val note = LocalCache.getOrCreateNote(key.toHexKey())
addNoteToReplyTos(note)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
@@ -78,15 +109,20 @@ class NewPostViewModel: ViewModel() {
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val index = addUserToMentionsIfNotInAndReturnIndex(user)
"#[${tagIndex(user)}]$restOfWord"
} else if (word.startsWith("@note") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val newWord = "#[${index}]"
val key = decodePublicKey(keyB32.removePrefix("@"))
val note = LocalCache.getOrCreateNote(key.toHexKey())
newWord + restOfWord
"#[${tagIndex(note)}]$restOfWord"
} else {
word
}
} catch (e: Exception) {
e.printStackTrace()
// if it can't parse the key, don't try to change.
word
}
@@ -102,6 +138,7 @@ class NewPostViewModel: ViewModel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun upload(it: Uri, context: Context) {
@@ -128,6 +165,7 @@ class NewPostViewModel: ViewModel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun findUrlInMessage(): String? {
@@ -161,7 +199,7 @@ class NewPostViewModel: ViewModel() {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkey.toNpub()} "
val wordToInsert = "@${item.pubkeyNpub()} "
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
@@ -29,19 +29,19 @@ class NewUserMetadataViewModel: ViewModel() {
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 ?: ""
lnURL.value = it.info.lud06 ?: ""
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 ?: ""
lnURL.value = it.info?.lud06 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().latestMetadata
val latest = account.userProfile().info?.latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
@@ -49,17 +49,17 @@ class NewUserMetadataViewModel: ViewModel() {
} 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("lud16", lnAddress.value)
currentJson.put("lud06", lnURL.value)
currentJson.put("name", userName.value.trim())
currentJson.put("username", userName.value.trim())
currentJson.put("display_name", displayName.value.trim())
currentJson.put("displayName", displayName.value.trim())
currentJson.put("picture", picture.value.trim())
currentJson.put("banner", banner.value.trim())
currentJson.put("website", website.value.trim())
currentJson.put("about", about.value.trim())
currentJson.put("nip05", nip05.value.trim())
currentJson.put("lud16", lnAddress.value.trim())
currentJson.put("lud06", lnURL.value.trim())
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.ui
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -61,8 +61,6 @@ fun AsyncImageProxy(
val base64 = Base64.getUrlEncoder().encodeToString(model.url.toByteArray())
val extension = model.url.split(".").lastOrNull()
println("https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64")
AsyncImage(
model = "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64",
contentDescription = contentDescription,
@@ -18,7 +18,7 @@ fun ClickableNoteTag(
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -9,13 +9,8 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import nostr.postr.toNpub
@Composable
fun ClickableRoute(
@@ -25,7 +20,7 @@ fun ClickableRoute(
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.liveMetadata.observeAsState()
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
@@ -38,12 +33,12 @@ fun ClickableRoute(
)
} else {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live.observeAsState()
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -55,7 +50,7 @@ fun ClickableRoute(
)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -15,7 +15,7 @@ fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.liveMetadata.observeAsState()
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
@@ -23,12 +23,15 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
@@ -36,7 +39,7 @@ fun ExpandableRichTextViewer(
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, tags, navController)
RichTextViewer(text, canPreview, modifier, tags, accountViewModel, navController)
if (content.length > 350 && !showFullText) {
Row(
@@ -2,11 +2,16 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
@@ -14,7 +19,9 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
@@ -45,12 +52,12 @@ fun isValidURL(url: String?): Boolean {
fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
navController: NavController
accountViewModel: AccountViewModel,
navController: NavController,
) {
Column(
Modifier
.animateContentSize()) {
Column(modifier = modifier.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
@@ -78,7 +85,7 @@ fun RichTextViewer(
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
TagLink(word, tags, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -97,7 +104,7 @@ fun RichTextViewer(
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
TagLink(word, tags, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -151,7 +158,7 @@ fun BechLink(word: String, navController: NavController) {
@Composable
fun TagLink(word: String, tags: List<List<String>>, navController: NavController) {
fun TagLink(word: String, tags: List<List<String>>, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
@@ -177,7 +184,17 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
if (note != null) {
ClickableNoteTag(note, navController)
//ClickableNoteTag(note, navController)
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f)),
isQuotedNote = true,
navController = navController)
} else {
Text(text = "${tags[index][1].toByteArray().toNote().toShortenHex()} ")
}
@@ -37,6 +37,7 @@ import java.util.Locale
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
@@ -70,7 +71,9 @@ fun TranslateableRichTextViewer(
ExpandableRichTextViewer(
toBeViewed,
canPreview,
modifier,
tags,
accountViewModel,
navController
)
@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object ChannelFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
lateinit var channel: Channel
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) {
account = accountLoggedIn
channel = LocalCache.getOrCreateChannel(channelId)
}
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
}
@@ -0,0 +1,28 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter: FeedFilter<Note>() {
var account: Account? = null
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.checkGetOrCreateUser(userId)
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val myAccount = account
val myUser = withUser
if (myAccount == null || myUser == null) return emptyList()
val messages = myAccount.userProfile().privateChatrooms[myUser] ?: return emptyList()
return messages.roomMessages.filter { myAccount.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
}
@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListKnownFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt
}?.lastOrNull {
it.event != null
}
}
val publicChannels = account.followingChannels().map {
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
}
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListNewFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = ChatroomListKnownFeedFilter.account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
!me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt
}?.lastOrNull {
it.event != null
}
}
return privateMessages.sortedBy { it.event?.createdAt }.reversed()
}
}
@@ -0,0 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
abstract class FeedFilter<T>() {
fun loadTop(): List<T> {
return feed().take(1000)
}
abstract fun feed(): List<T>
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object GlobalFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.sortedBy { it.event?.createdAt }
.reversed()
}
@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.service
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -7,10 +7,8 @@ import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
object HiddenAccountsFeedFilter: FeedFilter<User>() {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
override fun updateChannelFilters() {}
}
@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.events.TextNoteEvent
object HomeConversationsFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent)
&& it.author in user.follows
&& account.isAcceptable(it)
&& !it.isNewThread()
}
.sortedBy { it.event?.createdAt }
.reversed()
}
}
@@ -0,0 +1,35 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
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.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object HomeNewThreadFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent)
&& it.author in user.follows
&& account.isAcceptable(it)
&& it.isNewThread()
}
.sortedBy { it.event?.createdAt }
.reversed()
}
}
@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.service
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -10,13 +10,12 @@ import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
object NotificationFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile().taggedPosts
.filter { it.event != null }
.filter { account.isAcceptable(it) }
.filter { it.author == null || !account.isHidden(it.author!!) }
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
@@ -25,6 +24,4 @@ object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
object ThreadFeedFilter: FeedFilter<Note>() {
var noteId: String? = null
override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet()
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature(cachedSignatures) }
return eventsToWatch.sortedWith(order)
}
fun loadThread(noteId: String?) {
this.noteId = noteId
}
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileConversationsFeedFilter: FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId)
}
override fun feed(): List<Note> {
return user?.notes
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
}
@@ -1,23 +1,19 @@
package com.vitorpamplona.amethyst.service
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
object UserProfileFollowersFeedFilter: FeedFilter<User>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.users[userId]
resetFilters()
}
override fun feed(): List<User> {
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {}
}
@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
object UserProfileFollowsFeedFilter: FeedFilter<User>() {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.users[userId]
}
override fun feed(): List<User> {
return user?.follows?.filter { account.isAcceptable(it) }?.reversed() ?: emptyList()
}
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileNewThreadFeedFilter: FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
account = accountLoggedIn
user = LocalCache.checkGetOrCreateUser(userId)
}
override fun feed(): List<Note> {
return user?.notes
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
}
@@ -0,0 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
object UserProfileReportsFeedFilter: FeedFilter<Note>() {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.checkGetOrCreateUser(userId)
}
override fun feed(): List<Note> {
return user?.reports?.values?.flatten()?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
}
@@ -1,25 +1,18 @@
package com.vitorpamplona.amethyst.service
package com.vitorpamplona.amethyst.ui.dal
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.service.model.LnZapEvent
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileZapsDataSource: NostrDataSource<Pair<Note,Note>>("UserProfileZapsFeed") {
lateinit var account: Account
object UserProfileZapsFeedFilter: FeedFilter<Pair<Note,Note>>() {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
user = LocalCache.checkGetOrCreateUser(userId)
}
override fun feed(): List<Pair<Note,Note>> {
return (user?.zaps?.filter { it.value != null }?.toList()?.sortedBy { (it.second?.event as? LnZapEvent)?.amount }?.reversed() ?: emptyList()) as List<Pair<Note, Note>>
}
override fun updateChannelFilters() {}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.layout.wrapContentHeight
@@ -16,12 +15,18 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
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.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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -29,7 +34,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
val bottomNavigationItems = listOf(
@@ -40,7 +45,7 @@ val bottomNavigationItems = listOf(
)
@Composable
fun AppBottomBar(navController: NavHostController) {
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
val currentRoute = currentRoute(navController)
val coroutineScope = rememberCoroutineScope()
@@ -55,7 +60,7 @@ fun AppBottomBar(navController: NavHostController) {
) {
bottomNavigationItems.forEach { item ->
BottomNavigationItem(
icon = { NotifiableIcon(item, currentRoute) },
icon = { NotifiableIcon(item, currentRoute, accountViewModel) },
selected = currentRoute == item.route,
onClick = {
coroutineScope.launch {
@@ -89,7 +94,7 @@ fun AppBottomBar(navController: NavHostController) {
}
@Composable
private fun NotifiableIcon(item: Route, currentRoute: String?) {
private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel: AccountViewModel) {
Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = item.icon),
@@ -98,39 +103,48 @@ private fun NotifiableIcon(item: Route, currentRoute: String?) {
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return
val db = dbState.value
val notif = notifState.value
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
if (db != null && notif != null) {
if (item.hasNewItems(db.cache, notif.cache)) {
val context = LocalContext.current.applicationContext
LaunchedEffect(key1 = notif) {
hasNewItems = item.hasNewItems(account, notif.cache, context)
}
if (hasNewItems) {
Box(
Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
) {
Box(
Modifier
modifier = Modifier
.width(10.dp)
.height(10.dp)
.align(Alignment.TopEnd)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
) {
Box(
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.width(10.dp)
.height(10.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd
) {
Text(
"",
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
)
}
.wrapContentHeight()
.align(Alignment.TopEnd)
)
}
}
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -36,45 +35,33 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.Coil
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
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.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
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.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
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 java.net.URLEncoder
import kotlinx.coroutines.launch
@Composable
@@ -91,7 +78,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().liveMetadata.observeAsState()
val accountUserState by account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -141,12 +128,11 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrAccountDataSource.printCounter()
NostrChannelDataSource.printCounter()
NostrChatRoomDataSource.printCounter()
NostrChatroomDataSource.printCounter()
NostrChatroomListDataSource.printCounter()
NostrGlobalDataSource.printCounter()
NostrHomeDataSource.printCounter()
NostrNotificationDataSource.printCounter()
NostrSingleEventDataSource.printCounter()
NostrSearchEventOrUserDataSource.printCounter()
@@ -155,9 +141,6 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrThreadDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
NostrUserProfileFollowersDataSource.printCounter()
NostrUserProfileFollowsDataSource.printCounter()
NostrUserProfileZapsDataSource.printCounter()
println("Connected Relays: " + RelayPool.connectedRelays())
@@ -165,8 +148,8 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
println("Image Disk Cache ${(imageLoader.diskCache?.size ?: 0)/(1024*1024)}/${(imageLoader.diskCache?.maxSize ?: 0)/(1024*1024)} MB")
println("Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0)/(1024*1024)}/${(imageLoader.memoryCache?.maxSize ?: 0)/(1024*1024)} MB")
println("Notes: " + LocalCache.notes.size)
println("Users: " + LocalCache.users.size)
println("Notes: " + LocalCache.notes.filter { it.value.event != null }.size +"/"+ LocalCache.notes.size)
println("Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size +"/"+ LocalCache.users.size)
}
) {
Icon(
@@ -74,8 +74,8 @@ import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@Composable
fun DrawerContent(navController: NavHostController,
@@ -123,16 +123,16 @@ fun DrawerContent(navController: NavHostController,
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
val accountUserState by baseAccountUser.liveMetadata.observeAsState()
val accountUserState by baseAccountUser.live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val accountUserFollowsState by baseAccountUser.liveFollows.observeAsState()
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
val accountUserFollows = accountUserFollowsState?.user ?: return
val ctx = LocalContext.current.applicationContext
Box {
val banner = accountUser.info.banner
val banner = accountUser.info?.banner
if (banner != null && banner.isNotBlank()) {
AsyncImageProxy(
model = ResizeImage(banner, 150.dp),
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
@@ -12,21 +14,16 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
@@ -37,24 +34,24 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: @Composable (LocalCache, NotificationCache) -> Boolean = @Composable { _,_ -> false },
val hasNewItems: (Account, NotificationCache, Context) -> Boolean = { _,_,_ -> false },
val arguments: List<NamedNavArgument> = emptyList(),
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
) {
object Home : Route("Home", R.drawable.ic_home,
hasNewItems = { _, cache -> homeHasNewItems(cache) },
hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) },
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
)
object Search : Route("Search", R.drawable.ic_search,
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}
)
object Notification : Route("Notification", R.drawable.ic_notifications,
hasNewItems = { _, cache -> notificationHasNewItems(cache) },
hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) },
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }}
)
object Message : Route("Message", R.drawable.ic_dm,
hasNewItems = { _, cache -> messagesHasNewItems(cache) },
hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) },
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}
)
@@ -107,45 +104,32 @@ public fun currentRoute(navController: NavHostController): String? {
return navBackStackEntry?.destination?.route
}
@Composable
private fun homeHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
val lastTimeFollows = cache.load("HomeFollows", context)
private fun homeHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
val lastTime = cache.load("HomeFollows", context)
val homeFeed = NostrHomeDataSource.feed().take(100)
HomeNewThreadFeedFilter.account = account
val hasNewInFollows = homeFeed.filter {
it.isNewThread()
}.filter {
(it.event?.createdAt ?: 0) > lastTimeFollows
}.isNotEmpty()
return hasNewInFollows
return HomeNewThreadFeedFilter.feed().any {(it.event?.createdAt ?: 0) > lastTime }
}
@Composable
private fun notificationHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
val lastTime = cache.load("Notification", context)
return NostrNotificationDataSource.loadTop()
.filter { it.event != null && it.event!!.createdAt > lastTime }
.isNotEmpty()
NotificationFeedFilter.account = account
return NotificationFeedFilter.feed().any {(it.event?.createdAt ?: 0) > lastTime }
}
@Composable
private fun messagesHasNewItems(cache: NotificationCache): Boolean {
val context = LocalContext.current.applicationContext
return NostrChatroomListDataSource.feed().take(100).filter {
// only for known sources
val me = NostrChatroomListDataSource.account.userProfile()
it.channel == null && me.hasSentMessagesTo(it.author) && it.author != me
}.filter {
val lastTime = if (it.channel != null) {
cache.load("Channel/${it.channel!!.idHex}", context)
} else {
cache.load("Room/${it.author?.pubkeyHex}", context)
}
private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
ChatroomListKnownFeedFilter.account = account
NostrChatroomListDataSource.account.isAcceptable(it) && it.event != null && it.event!!.createdAt > lastTime
}.isNotEmpty()
return ChatroomListKnownFeedFilter.feed().any {
if (it.channel == null && it.author != account.userProfile()) {
val lastTime = cache.load("Room/${it.author?.pubkeyHex}", context)
(it.event?.createdAt ?: 0) > lastTime
} else {
false
}
}
}
@@ -1,39 +1,41 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.MaterialTheme
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.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.platform.LocalContext
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.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by boostSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -41,15 +43,38 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, rou
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote(Modifier, isInnerNote)
} else {
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
isNew = boostSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt, context)
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(modifier = Modifier
@@ -75,7 +100,7 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, rou
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
likeSetCard.boostEvents.forEach {
boostSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
@@ -85,7 +110,16 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, rou
}
}
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
accountViewModel = accountViewModel,
navController = navController
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -1,14 +1,11 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -20,8 +17,12 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
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.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
@@ -29,7 +30,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
@@ -37,21 +37,18 @@ import androidx.compose.ui.text.style.TextOverflow
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.LocalPreferences
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -65,7 +62,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
val authorState by note.author!!.liveMetadata.observeAsState()
val authorState by note.author!!.live().metadata.observeAsState()
val author = authorState?.user
val channelState by note.channel!!.live.observeAsState()
@@ -81,11 +78,13 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
noteEvent?.content
}
channel?.let { channel ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context)
else
false
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache) {
noteEvent?.let {
hasNewMessages = it.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context)
}
}
ChannelName(
channelPicture = channel.profilePicture(),
@@ -122,11 +121,13 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val noteEvent = note.event
userToComposeOn.let { user ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
else
false
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache) {
noteEvent?.let {
hasNewMessages = it.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
}
}
ChannelName(
channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) },
@@ -2,34 +2,29 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
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.mutableStateOf
@@ -46,12 +41,10 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
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.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.RoboHashCache
@@ -59,11 +52,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
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.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -73,13 +63,13 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteReportsState by baseNote.liveReports.observeAsState()
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountUser = account.userProfile()
@@ -115,15 +105,18 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
shape = ChatBubbleShapeThem
}
// Mark read
val isNew = routeForLastRead?.run {
val isNew = NotificationCache.load(this, context)
var isNew by remember { mutableStateOf<Boolean>(false) }
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(this, createdAt, context)
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it, context)
isNew
val createdAt = note.event?.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt, context)
isNew = createdAt > lastTime
}
}
}
Column() {
@@ -154,7 +147,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
val authorState by note.author!!.liveMetadata.observeAsState()
val authorState by note.author!!.live().metadata.observeAsState()
val author = authorState?.user!!
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
@@ -231,6 +224,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
TranslateableRichTextViewer(
eventContent,
canPreview,
Modifier,
note.event?.tags,
accountViewModel,
navController
@@ -239,6 +233,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
TranslateableRichTextViewer(
"Could Not decrypt the message",
true,
Modifier,
note.event?.tags,
accountViewModel,
navController
@@ -275,7 +270,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
@@ -289,8 +284,8 @@ private fun RelayBadges(baseNote: Note) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImageProxy(
model = ResizeImage("https://${url}/favicon.ico", 15.dp),
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
@@ -1,39 +1,41 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.MaterialTheme
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.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.platform.LocalContext
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.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by likeSetCard.note.live.observeAsState()
val noteState by likeSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -41,15 +43,38 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(modifier = Modifier
@@ -85,7 +110,16 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
}
}
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
accountViewModel = accountViewModel,
navController = navController
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,14 +22,13 @@ import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
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.mutableStateOf
@@ -39,10 +37,8 @@ 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.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -50,33 +46,26 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Dp
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.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
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.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import nostr.postr.events.TextNoteEvent
import nostr.postr.toNpub
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -84,17 +73,18 @@ fun NoteCompose(
baseNote: Note,
routeForLastRead: String? = null,
modifier: Modifier = Modifier,
isInnerNote: Boolean = false,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
var popupExpanded by remember { mutableStateOf(false) }
@@ -104,38 +94,41 @@ fun NoteCompose(
var moreActionsExpanded by remember { mutableStateOf(false) }
val noteEvent = note?.event
if (note?.event == null) {
if (noteEvent == null) {
BlankNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
), isBoostedNote)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isInnerNote,
isBoostedNote,
navController,
onClick = { showHiddenNote = true }
)
} else {
val isNew = routeForLastRead?.run {
val lastTime = NotificationCache.load(this, context)
var isNew by remember { mutableStateOf<Boolean>(false) }
val createdAt = note.event?.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(this, createdAt, context)
createdAt > lastTime
} else {
false
LaunchedEffect(key1 = routeForLastRead) {
routeForLastRead?.let {
val lastTime = NotificationCache.load(it, context)
val createdAt = noteEvent.createdAt
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt, context)
isNew = createdAt > lastTime
}
}
} ?: false
}
Column(modifier =
modifier.combinedClickable(
onClick = {
if (note.event !is ChannelMessageEvent) {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
@@ -157,12 +150,12 @@ fun NoteCompose(
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
top = 10.dp)
) {
if (!isInnerNote) {
if (!isBoostedNote && !isQuotedNote) {
Column(Modifier.width(55.dp)) {
// Draws the boosted picture outside the boosted card.
Box(modifier = Modifier
@@ -171,7 +164,7 @@ fun NoteCompose(
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
if (note.event is RepostEvent) {
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
@@ -187,7 +180,7 @@ fun NoteCompose(
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
if (noteEvent is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
@@ -219,7 +212,7 @@ fun NoteCompose(
}
}
if (note.event is RepostEvent) {
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
@@ -229,13 +222,18 @@ fun NoteCompose(
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Column(
modifier = Modifier.padding(start = if (!isBoostedNote && !isQuotedNote) 10.dp else 0.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isQuotedNote) {
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
}
NoteUsernameDisplay(note, Modifier.weight(1f))
if (note.event !is RepostEvent) {
if (noteEvent !is RepostEvent) {
Text(
timeAgo(note.event?.createdAt),
timeAgo(noteEvent.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
@@ -262,36 +260,55 @@ fun NoteCompose(
}
}
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
} else if (note.event is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
}
}
if (note.event is ReactionEvent || note.event is RepostEvent) {
if (noteEvent is ReactionEvent || noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
NoteCompose(
it,
modifier = Modifier,
isInnerNote = true,
isBoostedNote = true,
accountViewModel = accountViewModel,
navController = navController
)
}
// Reposts have trash in their contents.
if (note.event is ReactionEvent) {
if (noteEvent is ReactionEvent) {
val refactorReactionText =
if (note.event?.content == "+") "" else note.event?.content ?: " "
if (noteEvent.content == "+") "" else noteEvent.content ?: " "
Text(
text = refactorReactionText
)
}
} else if (noteEvent is ReportEvent) {
val reportType = noteEvent.reportType.map {
when (it) {
ReportEvent.ReportType.EXPLICIT -> "Explicit Content"
ReportEvent.ReportType.SPAM -> "Spam"
ReportEvent.ReportType.IMPERSONATION -> "Impersonation"
ReportEvent.ReportType.ILLEGAL -> "Illegal Behavior"
else -> "Unkown"
}
}.joinToString(", ")
Text(
text = reportType
)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else {
val eventContent = note.event?.content
val eventContent = noteEvent.content
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
@@ -300,7 +317,8 @@ fun NoteCompose(
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
Modifier.fillMaxWidth(),
noteEvent.tags,
accountViewModel,
navController
)
@@ -323,7 +341,7 @@ fun NoteCompose(
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
@@ -340,8 +358,8 @@ private fun RelayBadges(baseNote: Note) {
Modifier
.size(15.dp)
.padding(1.dp)) {
AsyncImageProxy(
model = ResizeImage("https://${url}/favicon.ico", 15.dp),
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
error = BitmapPainter(RoboHashCache.get(ctx, url)),
@@ -400,7 +418,7 @@ fun NoteAuthorPicture(
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
@@ -447,7 +465,7 @@ fun UserPicture(
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
@@ -476,7 +494,7 @@ fun UserPicture(
)
val accountState by baseUserAccount.liveFollows.observeAsState()
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
@@ -522,10 +540,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkeyNpub() ?: "")); onDismiss() }) {
Text("Copy User PubKey")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
Text("Copy Note ID")
}
Divider()
@@ -1,24 +1,39 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.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.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.ripple.rememberRipple
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.mutableStateOf
@@ -26,43 +41,63 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import java.math.BigDecimal
import java.math.RoundingMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val reactionsState by baseNote.liveReactions.observeAsState()
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note
val boostsState by baseNote.liveBoosts.observeAsState()
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
val zapsState by baseNote.liveZaps.observeAsState()
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val repliesState by baseNote.liveReplies.observeAsState()
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
@@ -74,8 +109,20 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
mutableStateOf<Note?>(null)
}
var wantsToQuote by remember {
mutableStateOf<Note?>(null)
}
if (wantsToReplyTo != null)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account)
if (wantsToQuote != null)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToBoost by remember { mutableStateOf(false) }
Row(
modifier = Modifier
@@ -118,7 +165,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
accountViewModel.boost(baseNote)
wantsToBoost = true
else
scope.launch {
Toast.makeText(
@@ -129,6 +176,20 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
}
}
) {
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
wantsToQuote = baseNote
}
)
}
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
@@ -192,26 +253,69 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable()) {
accountViewModel.zap(baseNote, 1000 * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
Row(
modifier = Modifier
.then(Modifier.size(20.dp))
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
"No Zap Amount Setup. Long Press to change",
Toast.LENGTH_SHORT
)
.show()
}
} else if (!account.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
}
},
onLongClick = {
wantsToChangeZapAmount = true
}
} else {
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
).show()
}
}
}
)
) {
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToZap = false
},
onChangeAmount = {
wantsToZap = false
wantsToChangeZapAmount = true
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
@@ -236,7 +340,6 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
@@ -265,6 +368,218 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
onDismissRequest = { onDismiss() }
) {
FlowRow() {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.boost(baseNote)
onDismiss()
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("Boost", color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onQuote,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("Quote", color = Color.White, textAlign = TextAlign.Center)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
onDismissRequest = { onDismiss() }
) {
FlowRow() {
account.zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
onDismiss()
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("${showAmount(amountInSats.toBigDecimal())}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
onDismiss()
},
onLongClick = {
onChangeAmount()
},
)
)
}
}
}
}
}
class UpdateZapAmountViewModel: ViewModel() {
private var account: Account? = null
var amounts by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
this.account = account
this.amounts = TextFieldValue(account.zapAmountChoices.joinToString(", "))
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
}
fun updateAmounts(commaSeparatedAmounts: TextFieldValue) {
val correctedText = toListOfAmounts(commaSeparatedAmounts.text).joinToString(", ")
amounts = TextFieldValue(correctedText, commaSeparatedAmounts.selection, commaSeparatedAmounts.composition)
}
fun sendPost() {
account?.changeZapAmounts(toListOfAmounts(amounts.text))
amounts = TextFieldValue("")
}
fun cancel() {
amounts = TextFieldValue("")
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val postViewModel: UpdateZapAmountViewModel = viewModel()
val ctx = LocalContext.current.applicationContext
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(account) {
postViewModel.load(account)
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
SaveButton(
onPost = {
postViewModel.sendPost()
LocalPreferences(ctx).saveToEncryptedStorage(account)
onClose()
},
isActive = postViewModel.amounts.text.isNotBlank()
)
}
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
label = { Text(text = "Comma-separated Zap amounts in sats") },
value = postViewModel.amounts,
onValueChange = {
postViewModel.updateAmounts(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Decimal
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
}
)
}
}
}
}
}
fun showCount(count: Int?): String {
if (count == null) return " "
if (count == 0) return " "
@@ -22,6 +22,7 @@ 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 com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.time.Instant
@@ -30,7 +31,7 @@ import java.time.format.DateTimeFormatter
@Composable
fun RelayCompose(
relay: User.RelayInfo,
relay: RelayInfo,
accountViewModel: AccountViewModel,
navController: NavController,
onAddRelay: () -> Unit,
@@ -44,7 +44,7 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, prefix: String
val mentionSet = mentions.toSet()
mentionSet.toSet().forEachIndexed { idx, user ->
val innerUserState by user.liveMetadata.observeAsState()
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
@@ -123,7 +123,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?,
val mentionSet = mentions.toSet()
mentionSet.forEachIndexed { idx, user ->
val innerUserState by user.liveMetadata.observeAsState()
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
@@ -34,7 +34,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by account.userProfile().liveFollows.observeAsState()
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
@@ -60,11 +60,11 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about ?: "",
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -14,7 +14,7 @@ import com.vitorpamplona.amethyst.model.User
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
@@ -26,7 +26,7 @@ fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
if (user.bestUsername() != null || user.bestDisplayName() != null) {
@@ -53,7 +53,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
}
} else {
Text(
user.pubkeyDisplayHex,
user.pubkeyDisplayHex(),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -4,7 +4,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
@@ -21,7 +20,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.screen.FollowButton
@@ -35,13 +33,13 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by account.userProfile().liveFollows.observeAsState()
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val noteState by baseNote.second.live.observeAsState()
val noteState by baseNote.second.live().metadata.observeAsState()
val noteZap = noteState?.note ?: return
val baseNoteRequest by baseNote.first.live.observeAsState()
val baseNoteRequest by baseNote.first.live().metadata.observeAsState()
val noteZapRequest = baseNoteRequest?.note ?: return
val baseAuthor = noteZapRequest.author
@@ -74,11 +72,11 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
UsernameDisplay(baseAuthor)
}
val userState by baseAuthor.liveMetadata.observeAsState()
val userState by baseAuthor.live().metadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about ?: "",
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,26 +14,28 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
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.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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by zapSetCard.note.live.observeAsState()
val noteState by zapSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -39,15 +43,38 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
val context = LocalContext.current.applicationContext
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
val isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(modifier = Modifier
@@ -83,7 +110,16 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
}
}
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
accountViewModel = accountViewModel,
navController = navController
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -38,8 +38,8 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import nostr.postr.toNpub
@@ -112,7 +112,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
}
}
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.LocalPreferences
@@ -7,7 +9,9 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
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
@@ -21,9 +25,13 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences): Vie
init {
// pulls account from storage.
// Keeps it in the the UI thread to void blinking the login page.
//viewModelScope.launch(Dispatchers.IO) {
localPreferences.loadFromEncryptedStorage()?.let {
login(it)
}
//}
}
fun login(key: String) {
@@ -58,7 +66,8 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences): Vie
else
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
viewModelScope.launch(Dispatchers.IO) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
}
}
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
@@ -16,7 +15,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
@@ -96,7 +94,7 @@ private fun FeedLoaded(
when (item) {
is NoteCard -> NoteCompose(
item.note,
isInnerNote = false,
isBoostedNote = false,
accountViewModel = accountViewModel,
navController = navController,
routeForLastRead = routeForLastRead
@@ -1,24 +1,22 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -26,7 +24,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
object NotificationViewModel: CardFeedViewModel(NotificationFeedFilter)
open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -38,6 +38,8 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
refreshSuspended()
}
}
@Synchronized
private fun refreshSuspended() {
val notes = dataSource.loadTop()
@@ -125,10 +127,14 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
if (feedContent.value is CardFeedState.Loaded)
delay(5000)
refresh()
handlerWaiting.set(false)
try {
delay(150)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
@@ -23,20 +22,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
fun FeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
navController: NavController,
routeForLastRead: String?
) {
val feedState by viewModel.feedContent.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
@@ -44,7 +43,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
viewModel.hardRefresh()
viewModel.refresh()
isRefreshing = false
}
}
@@ -105,7 +104,7 @@ private fun FeedLoaded(
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
NoteCompose(
item,
isInnerNote = false,
isBoostedNote = false,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
navController = navController
@@ -2,22 +2,26 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrDataSource
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.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -25,56 +29,25 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
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.hasSentMessagesTo(it.author)
}
}
}
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.hasSentMessagesTo(it.author)
}
}
}
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no replies
return dataSource.feed().filter { it.isNewThread() }.take(1000)
}
}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: only replies
return dataSource.feed().filter {! it.isNewThread() }.take(1000)
}
}
class NostrChannelFeedViewModel: FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel: FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel: FeedViewModel(GlobalFeedFilter)
class NostrThreadFeedViewModel: FeedViewModel(ThreadFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel: FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel: FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel: FeedViewModel(UserProfileReportsFeedFilter)
class NostrChatroomListKnownFeedViewModel: FeedViewModel(ChatroomListKnownFeedFilter)
class NostrChatroomListNewFeedViewModel: FeedViewModel(ChatroomListNewFeedFilter)
class NostrHomeFeedViewModel: FeedViewModel(HomeNewThreadFeedFilter)
class NostrHomeRepliesFeedViewModel: FeedViewModel(HomeConversationsFeedFilter)
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
abstract class FeedViewModel(val localFilter: FeedFilter<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 newListFromDataSource(): List<Note> {
return localFilter.loadTop()
}
fun refresh() {
@@ -90,7 +63,7 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
if (notes != oldNotesState.feed.value) {
updateFeed(notes)
}
} else {
@@ -102,7 +75,6 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
@@ -116,16 +88,20 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
private var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
if (feedContent.value is FeedState.Loaded)
delay(5000)
refresh()
handlerWaiting.set(false)
try {
delay(50)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -2,16 +2,14 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -19,10 +17,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.NonCancellable
class NostrUserProfileZapsFeedViewModel: LnZapFeedViewModel(NostrUserProfileZapsDataSource)
class NostrUserProfileZapsFeedViewModel: LnZapFeedViewModel(UserProfileZapsFeedFilter)
open class LnZapFeedViewModel(val dataSource: NostrDataSource<Pair<Note, Note>>): ViewModel() {
open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>): ViewModel() {
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -33,14 +32,13 @@ open class LnZapFeedViewModel(val dataSource: NostrDataSource<Pair<Note, Note>>)
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
if (notes.size != oldNotesState.feed.value.size || notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
updateFeed(notes)
}
} else {
@@ -72,8 +70,14 @@ open class LnZapFeedViewModel(val dataSource: NostrDataSource<Pair<Note, Note>>)
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(1000)
refresh()
try {
delay(50)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
handlerWaiting.set(false)
}
}
@@ -19,6 +19,7 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavController
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
@@ -35,9 +36,9 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class RelayFeedViewModel: ViewModel() {
val order = compareByDescending<User.RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
val order = compareByDescending<RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
private val _feedContent = MutableStateFlow<List<User.RelayInfo>>(emptyList())
private val _feedContent = MutableStateFlow<List<RelayInfo>>(emptyList())
val feedContent = _feedContent.asStateFlow()
var currentUser: User? = null
@@ -49,7 +50,7 @@ class RelayFeedViewModel: ViewModel() {
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
User.RelayInfo(it.key, 0, 0)
RelayInfo(it.key, 0, 0)
} else {
null
}
@@ -67,14 +68,14 @@ class RelayFeedViewModel: ViewModel() {
fun subscribeTo(user: User) {
currentUser = user
user.liveRelays.observeForever(listener)
user.liveRelayInfo.observeForever(listener)
user.live().relays.observeForever(listener)
user.live().relayInfo.observeForever(listener)
invalidateData()
}
fun unsubscribeTo(user: User) {
user.liveRelays.removeObserver(listener)
user.liveRelayInfo.removeObserver(listener)
user.live().relays.removeObserver(listener)
user.live().relayInfo.removeObserver(listener)
currentUser = null
}
@@ -3,20 +3,15 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -28,20 +23,15 @@ 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.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.text.style.TextDirection
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
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.HiddenNote
@@ -49,8 +39,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -117,7 +105,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
NoteCompose(
item,
modifier = Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
isInnerNote = false,
isBoostedNote = false,
accountViewModel = accountViewModel,
navController = navController,
)
@@ -162,10 +150,10 @@ fun Modifier.drawReplyLevel(level: Int, color: Color): Modifier = this
@Composable
fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val noteState by baseNote.live.observeAsState()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -175,7 +163,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
if (note?.event == null) {
BlankNote()
} else if (!account.isAcceptable(noteForReports)) {
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
@@ -230,6 +218,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
TranslateableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
note.event?.tags,
accountViewModel,
navController
@@ -1,43 +1,31 @@
package com.vitorpamplona.amethyst.ui.screen
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class NostrUserProfileFollowsUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowsDataSource
)
class NostrUserProfileFollowsUserFeedViewModel: UserFeedViewModel(UserProfileFollowsFeedFilter)
class NostrUserProfileFollowersUserFeedViewModel: UserFeedViewModel(UserProfileFollowersFeedFilter)
class NostrHiddenAccountsFeedViewModel: UserFeedViewModel(HiddenAccountsFeedFilter)
class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowersDataSource
)
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
NostrHiddenAccountsDataSource
)
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
open class UserFeedViewModel(val dataSource: FeedFilter<User>): ViewModel() {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -48,14 +36,13 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes.size != oldNotesState.feed.value.size && notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
if (notes.size != oldNotesState.feed.value.size || notes.firstOrNull() != oldNotesState.feed.value.firstOrNull()) {
updateFeed(notes)
}
} else {
@@ -87,9 +74,14 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
if (feedContent.value is UserFeedState.Loaded)
delay(5000)
refresh()
try {
delay(50)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
handlerWaiting.set(false)
}
}
@@ -25,7 +25,7 @@ class AccountViewModel(private val account: Account): ViewModel() {
}
fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) {
val lud16 = note.author?.info?.lud16
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError("User does not have a lightning address setup to receive sats")
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -51,6 +53,8 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
@@ -62,11 +66,13 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
@@ -79,15 +85,36 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
if (account != null && channelId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
ChannelFeedFilter.loadMessagesBetween(account, channelId)
NostrChannelDataSource.loadMessagesBetween(channelId)
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
val channel = channelState?.channel ?: return
val feedViewModel: NostrChannelFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.refresh()
feedViewModel.invalidateData()
}
DisposableEffect(channelId) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
NostrChannelDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Channel Stop")
NostrChannelDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
@@ -244,7 +271,7 @@ private fun NoteCopyButton(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); popupExpanded = false }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); popupExpanded = false }) {
Text("Copy Channel ID (Note) to the Clipboard")
}
}
@@ -9,12 +9,16 @@ import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.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.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
@@ -22,6 +26,10 @@ 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.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -72,11 +80,35 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
@Composable
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
ChatroomListKnownFeedFilter.account = account
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.hardRefresh() // refresh filters
feedViewModel.refresh() // refresh view
NostrChatroomListDataSource.resetFilters()
feedViewModel.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
@@ -90,13 +122,37 @@ fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
@Composable
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
ChatroomListNewFeedFilter.account = account
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.hardRefresh() // refresh filters
NostrChatroomListDataSource.resetFilters()
feedViewModel.refresh() // refresh view
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Chatroom List Start")
NostrChatroomListDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Chatroom List Stop")
NostrChatroomListDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
@@ -19,35 +19,36 @@ import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.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.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -59,16 +60,37 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
if (account != null && userId != null) {
val newPost = remember { mutableStateOf(TextFieldValue("")) }
NostrChatRoomDataSource.loadMessagesBetween(account, userId)
ChatroomFeedFilter.loadMessagesBetween(account, userId)
NostrChatroomDataSource.loadMessagesBetween(account, userId)
val feedViewModel: NostrChatRoomFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.refresh()
feedViewModel.invalidateData()
}
DisposableEffect(userId) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")
NostrChatroomDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
NostrChatRoomDataSource.withUser?.let {
NostrChatroomDataSource.withUser?.let {
ChatroomHeader(
it,
accountViewModel = accountViewModel,
@@ -140,7 +162,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.liveMetadata.observeAsState()
val authorState by baseUser.live().metadata.observeAsState()
val author = authorState?.user!!
AsyncImageProxy(
@@ -9,7 +9,6 @@ import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
@@ -21,8 +20,7 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -33,7 +31,7 @@ fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavControll
val account = accountState?.account
if (account != null) {
NostrHiddenAccountsDataSource.account = account
HiddenAccountsFeedFilter.account = account
val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel()
@@ -9,16 +9,26 @@ import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.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.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@@ -26,6 +36,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)
@Composable
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
val feedViewModel: NostrHomeFeedViewModel = viewModel()
val feedViewModelReplies: NostrHomeRepliesFeedViewModel = viewModel()
@@ -33,8 +49,31 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
feedViewModel.refresh()
feedViewModelReplies.refresh()
NostrHomeDataSource.resetFilters()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Home Start")
NostrHomeDataSource.start()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Home Stop")
NostrHomeDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
@@ -38,7 +38,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
.background(MaterialTheme.colors.primaryVariant)
.statusBarsPadding(),
bottomBar = {
AppBottomBar(navController)
AppBottomBar(navController, accountViewModel)
},
topBar = {
AppTopBar(navController, scaffoldState, accountViewModel)
@@ -11,13 +11,17 @@ 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.NostrNotificationDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
NotificationFeedFilter.account = account
val feedViewModel: NotificationViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -1,11 +1,8 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
@@ -40,37 +37,35 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.ResizeImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import nostr.postr.toNsec
@OptIn(ExperimentalPagerApi::class)
@@ -81,10 +76,14 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
if (userId == null) return
UserProfileNewThreadFeedFilter.loadUserProfile(account, userId)
UserProfileConversationsFeedFilter.loadUserProfile(account, userId)
UserProfileFollowersFeedFilter.loadUserProfile(account, userId)
UserProfileFollowsFeedFilter.loadUserProfile(account, userId)
UserProfileZapsFeedFilter.loadUserProfile(userId)
UserProfileReportsFeedFilter.loadUserProfile(userId)
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
NostrUserProfileZapsDataSource.loadUserProfile(userId)
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -92,23 +91,22 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profile Start")
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileDataSource.start()
NostrUserProfileFollowersDataSource.start()
NostrUserProfileFollowsDataSource.start()
NostrUserProfileZapsDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Profile Stop")
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileZapsDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
println("Profile Dispose")
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
}
}
@@ -167,75 +165,72 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
tabsSize = it
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
text = {
val tabs = listOf<@Composable() (() -> Unit)?>(
{
Text(text = "Notes")
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
},
{
Text(text = "Replies")
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
val userState by baseUser.liveFollows.observeAsState()
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
}
)
Tab(
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
val userState by baseUser.liveZaps.observeAsState()
},
{
val userState by baseUser.live().zaps.observeAsState()
val userZaps = userState?.user?.zappedAmount()
Text(text = "${showAmount(userZaps)} Zaps")
}
)
},
{
val userState by baseUser.live().reports.observeAsState()
val userReports = userState?.user?.reports?.values?.flatten()?.count()
Tab(
selected = pagerState.currentPage == 4,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } },
text = {
val userState by baseUser.liveRelays.observeAsState()
Text(text = "${userReports} Reports")
},
{
val userState by baseUser.live().relays.observeAsState()
val userRelaysBeingUsed =
userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.liveRelayInfo.observeAsState()
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
}
)
tabs.forEachIndexed { index, function ->
Tab(
selected = pagerState.currentPage == index,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
text = function
)
}
}
HorizontalPager(
count = 5,
count = 7,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
}
) {
when (pagerState.currentPage) {
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabReceivedZaps(baseUser, accountViewModel, navController)
4 -> TabRelays(baseUser, accountViewModel, navController)
0 -> TabNotesNewThreads(baseUser, accountViewModel, navController)
1 -> TabNotesConversations(baseUser, accountViewModel, navController)
2 -> TabFollows(baseUser, accountViewModel, navController)
3 -> TabFollowers(baseUser, accountViewModel, navController)
4 -> TabReceivedZaps(baseUser, accountViewModel, navController)
5 -> TabReports(baseUser, accountViewModel, navController)
6 -> TabRelays(baseUser, accountViewModel, navController)
}
}
}
@@ -254,7 +249,7 @@ private fun ProfileHeader(
val ctx = LocalContext.current.applicationContext
var popupExpanded by remember { mutableStateOf(false) }
val accountUserState by account.userProfile().liveFollows.observeAsState()
val accountUserState by account.userProfile().live().follows.observeAsState()
val accountUser = accountUserState?.user ?: return
Box {
@@ -348,24 +343,28 @@ private fun ProfileHeader(
@Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account) {
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
"@${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
user.bestDisplayName()?.let {
Text( "$it",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
val website = user.info.website
user.bestUsername()?.let {
Text(
"@$it",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
val website = user.info?.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -377,7 +376,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { user.info.website?.let { runCatching { uri.openUri(it) } } },
onClick = { website.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
@@ -386,7 +385,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
var ZapExpanded by remember { mutableStateOf(false) }
val lud16 = user.info.lud16
val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim()
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -414,19 +413,21 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
}
}
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
user.info?.about?.let {
Text(
"$it",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
}
}
@Composable
private fun DrawBanner(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val banner = user.info.banner
val banner = user.info?.banner
if (banner != null && banner.isNotBlank()) {
AsyncImageProxy(
@@ -450,10 +451,30 @@ private fun DrawBanner(baseUser: User) {
}
@Composable
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
fun TabNotesNewThreads(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileFeedViewModel = viewModel()
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
}
@Composable
fun TabNotesConversations(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
@@ -523,6 +544,26 @@ fun TabReceivedZaps(user: User, accountViewModel: AccountViewModel, navControlle
}
}
@Composable
fun TabReports(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileReportFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
}
@Composable
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: RelayFeedViewModel = viewModel()
@@ -603,7 +644,7 @@ private fun NPubCopyButton(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); popupExpanded = false }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) {
Text("Copy Public Key (NPub) to the Clipboard")
}
}
@@ -721,7 +762,7 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() }) {
Text("Copy User ID")
}
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
@@ -76,11 +77,16 @@ import kotlinx.coroutines.withContext
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
GlobalFeedFilter.account = account
NostrGlobalDataSource.account = account
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.refresh()
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
@@ -88,6 +94,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")
@@ -216,6 +223,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
}
}
},
singleLine = true,
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
@@ -289,11 +297,11 @@ fun UserLine(
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about?.take(100) ?: "",
user.info?.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -16,10 +16,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
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.NostrUserProfileZapsDataSource
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -28,31 +25,36 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")
NostrThreadDataSource.start()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
NostrThreadDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
if (account != null && noteId != null) {
NostrThreadDataSource.loadThread(noteId)
val feedViewModel: NostrThreadFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.refresh()
ThreadFeedFilter.loadThread(noteId)
NostrThreadDataSource.loadThread(noteId)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")
ThreadFeedFilter.loadThread(noteId)
NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop")
ThreadFeedFilter.loadThread(null)
NostrThreadDataSource.loadThread(null)
NostrThreadDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
+2 -2
View File
@@ -7,8 +7,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.4.0' apply false
id 'com.android.library' version '7.4.0' apply false
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
id 'org.jetbrains.kotlin.jvm' version '1.8.0' apply false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB