Compare commits

...
76 Commits
Author SHA1 Message Date
Vitor Pamplona 7007796497 v0.56.4 2023-06-09 18:16:42 -04:00
Vitor Pamplona 9f030ffbf8 BugFix for crashing on start up due to creating two caches for videos. 2023-06-09 18:16:03 -04:00
Vitor Pamplona 9ca172e0fc v0.56.3 2023-06-09 17:12:29 -04:00
Vitor Pamplona 360758a2f3 Adds a networking state monitoring services to reconnect when the phone disconnects from relays. 2023-06-09 17:08:35 -04:00
Vitor Pamplona ffe6f4b55c Fixes F-droid version for mutable colors as background. 2023-06-09 16:43:47 -04:00
Vitor Pamplona 267b5e474c Adds another check to make sure the event does not exist in the local memory before notifying. 2023-06-09 16:41:14 -04:00
Vitor Pamplona bf6bc5ba68 Moves the recomposition due to background color change to each compose node. 2023-06-09 16:35:56 -04:00
Vitor Pamplona 1efc4c91e2 Reactors base note rendering to wrap the QuickAction menu 2023-06-09 14:07:38 -04:00
Vitor Pamplona a898196571 Refactoring shapes to the appropriate file. 2023-06-09 13:45:46 -04:00
Vitor Pamplona c158c7fc3d Moves the clipboard starters to their own compositions. 2023-06-09 13:19:14 -04:00
Vitor Pamplona e3d83fe39a improving the speed of the highlighter rendering 2023-06-09 11:27:27 -04:00
Vitor Pamplona 5327e1ddb2 Final touches on borders 2023-06-09 11:27:02 -04:00
Vitor Pamplona a2ad13c54a More spacing refinements for notes. 2023-06-09 10:49:10 -04:00
Vitor Pamplona 816a22ec69 More adjustments to borders and spacing between screen elements for different event types 2023-06-09 10:25:06 -04:00
Vitor Pamplona 8a99dc2db4 Removes unnecessary imports. 2023-06-09 10:03:11 -04:00
Vitor Pamplona f9cfe1479b - Breaks down the Note Composition stack further
- Fixes some border issues between multiple note types
- Aligns Quick Actions to the center of the note.
2023-06-09 10:02:56 -04:00
Vitor Pamplona 5d8aa7bb58 Refactoring variable name in Reactions 2023-06-09 10:00:37 -04:00
Vitor Pamplona b3e2b78415 v0.56.2 2023-06-08 18:16:53 -04:00
Vitor Pamplona d0094cc781 Removing duplicated ID protections from Notifications. Looks like we removed all the issues here. 2023-06-08 17:47:10 -04:00
Vitor Pamplona cb77298f52 Correctly processing multiple matching via find and not match 2023-06-08 17:46:47 -04:00
Vitor Pamplona 24532c8f20 Looks like there is never nothing to restore, so removing this. 2023-06-08 16:31:22 -04:00
Vitor Pamplona b641684377 Uses default thread to wait for a 250 milliseconds before navigating away from the video upload page. 2023-06-08 16:31:04 -04:00
Vitor Pamplona 9589f68028 Moves sending message on channel from the main thread to the IO thread 2023-06-08 16:30:29 -04:00
Vitor Pamplona 8b852d32e4 BugFix for notifications sometimes not reassembling when pressing the bottom button. 2023-06-08 16:28:48 -04:00
Vitor Pamplona 5b864fa1d9 Adds a second rule for short messages in the spam filter to avoid recognizing "@Bot, command" messages spam. 2023-06-08 15:52:35 -04:00
Vitor Pamplona 8e91987c31 Refactors search screen to make the SearchTextField @Stable 2023-06-08 15:51:46 -04:00
Vitor Pamplona 1c3b34a9c7 Moves the creation of the VideoPlayer to its own composition 2023-06-08 14:22:22 -04:00
Vitor PamplonaandGitHub 5a44f8d663 Merge pull request #448 from TonyGiorgio/onyx-hashtag
Add onyx hashtag
2023-06-08 12:30:36 -04:00
Vitor Pamplona 2ab7e6b24d v0.56.1 2023-06-08 12:18:06 -04:00
Vitor PamplonaandGitHub e9f8fd6cec Merge pull request #447 from TonyGiorgio/weed-hashtag
Add weed hashtag
2023-06-08 12:17:31 -04:00
Tony Giorgio 8d7060afe6 Add onyx hashtag 2023-06-08 11:15:47 -05:00
Vitor Pamplona 784f983746 Image and Video Compression 2023-06-08 12:14:26 -04:00
Tony Giorgio ecc4141f50 Add weed hashtag 2023-06-08 11:12:20 -05:00
Vitor PamplonaandGitHub b964c6a7fa Merge pull request #444 from ZsZolee/main
Adding one small Hungarian translation
2023-06-07 19:17:03 -04:00
Vitor PamplonaandGitHub 5d37e337bc Merge branch 'main' into main 2023-06-07 19:16:56 -04:00
Vitor Pamplona d9815b669b v0.56.0 2023-06-07 18:55:34 -04:00
Vitor Pamplona 6906993cc3 Support to per-post display reactions 2023-06-07 18:50:29 -04:00
Vitor Pamplona 705309cb8a v0.55.4 2023-06-07 17:17:46 -04:00
Vitor Pamplona 89bdc9dd58 Adds support for RelaySet event types. 2023-06-07 17:16:48 -04:00
Vitor Pamplona ac0103a53e v0.55.3 2023-06-07 15:57:31 -04:00
Vitor Pamplona b2c31d3b46 Avoid reusing SHA256 object because of a memory leak in multithreading 2023-06-07 15:49:15 -04:00
Vitor Pamplona 931bd58c51 Removes main thread checks on Verification due to the need to mock this method when running tests. 2023-06-07 13:59:12 -04:00
Vitor Pamplona 6a7d6a843d Minor updates to remember states in composition 2023-06-07 12:26:32 -04:00
Vitor Pamplona eaae672a30 Further breaks down the bottom bar to facilitate recompositions. 2023-06-07 12:25:49 -04:00
Vitor Pamplona ccfbfeffe2 Moves ChatroomList's New Feed to an Additive filter. 2023-06-07 12:25:11 -04:00
Vitor Pamplona eb62da8d26 Refactors the top bar relay updates. 2023-06-07 12:24:49 -04:00
Vitor Pamplona d334c6b2e1 BugFix for the Notification dot not immediately disappearing after marking all as read. 2023-06-07 12:24:24 -04:00
Vitor Pamplona 90f0e4aad1 Refactoring duplicated code in Clickable Route 2023-06-07 12:23:30 -04:00
Vitor Pamplona fabf52ffd7 Moves channel name to a remember state 2023-06-07 12:22:53 -04:00
Vitor Pamplona c499c4baec Adds more main thread checks to make sure slow procedures are run outside the main thread. 2023-06-07 12:08:13 -04:00
Vitor Pamplona 4bd19c3e3d Deletes hiddenUsers method from account to avoid misuse since it needs a blocking load from cache. 2023-06-07 12:07:03 -04:00
Vitor Pamplona 6a47f5aa3b Moves notification's scroll to top animation to an IO thread. 2023-06-07 12:06:18 -04:00
Vitor Pamplona 03d1754498 Moves collection events to an IO thread. 2023-06-07 12:05:45 -04:00
Vitor Pamplona e9374370b9 Moves author creation for Addressable notes to outside a @synchronized block to avoid deadlocks. 2023-06-07 12:04:50 -04:00
Vitor Pamplona 88a5e309bd Removes kinds from the filter since relays only allow 20 options. In this way, we get everything and filter it locally. 2023-06-07 12:03:44 -04:00
ZoltanandGitHub f7a292598e Adding one small Hungarian translation 2023-06-07 11:00:21 +02:00
Vitor Pamplona cff8684347 updating firebase version 2023-06-06 16:47:28 -04:00
Vitor Pamplona b9654d164f Further splitting the translation class into more methods to simplify recompositions 2023-06-06 16:47:14 -04:00
Vitor Pamplona c10db10430 Moving unstable parameters into stable ones to reduce recompositions. 2023-06-06 15:51:43 -04:00
Vitor Pamplona 50f3f7f176 Fixes a search bug that wouldn't update relay filters correctly with the search terms 2023-06-06 15:50:21 -04:00
Vitor Pamplona 49c3334509 activates compose reports 2023-06-06 15:49:52 -04:00
Vitor Pamplona e75f70985a upping memory requirements for the build 2023-06-06 15:49:29 -04:00
Vitor Pamplona e956f51f19 v0.55.2 2023-06-05 18:22:26 -04:00
Vitor Pamplona 165678f118 formatting 2023-06-05 18:22:09 -04:00
Vitor Pamplona d32d2da280 Increases the speed of the Zap Tab in Profiles. 2023-06-05 18:19:23 -04:00
Vitor PamplonaandGitHub d6a6a52821 Merge pull request #441 from davotoula/german_translation
Added German translation
2023-06-05 16:39:50 -04:00
Vitor Pamplona dfb697975f v0.55.1 2023-06-05 16:06:32 -04:00
Vitor Pamplona c0c0fd9e6d Fixes tests 2023-06-05 16:06:25 -04:00
Vitor Pamplona 5bdcdc6f4a - Refactoring feeds use of the pullrefresh
- Adding more places to check if in the main thread.
2023-06-05 15:33:16 -04:00
David Kaspar 102ab63673 Added German translation 2023-06-04 20:41:48 +01:00
Vitor Pamplona b34c1f98d5 Marks some methods to never run on the Main Thread 2023-06-04 12:17:19 -04:00
Vitor Pamplona 74b05e3d6a Simplifying recompositions of NIP05 2023-06-04 12:17:02 -04:00
Vitor Pamplona 972bce426a No need to display replying information anymore. 2023-06-04 11:56:53 -04:00
Vitor Pamplona 3de57d6ae2 Moves memory cleanup to an IO thread 2023-06-04 11:56:39 -04:00
Vitor Pamplona 5695a82c20 Fixes: https://github.com/vitorpamplona/amethyst/issues/438 2023-06-04 11:45:34 -04:00
Vitor Pamplona e171120836 Moves relay state to a new watcher 2023-06-03 21:46:26 -04:00
139 changed files with 5439 additions and 2828 deletions
+2 -2
View File
@@ -61,10 +61,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
+8 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 195
versionName "0.55.0"
versionCode 204
versionName "0.56.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -176,7 +176,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -188,6 +188,11 @@ dependencies {
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -10,8 +12,8 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) = ExpandableRichTextViewer(
+18 -2
View File
@@ -4,17 +4,33 @@
<queries>
<package android:name="org.torproject.android"/>
</queries>
<!-- Doesn't require a camera -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- To Upload media (old devices) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- To read QRs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To read NFCs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
<!-- To know receive notifications when the app connects/disconnects from the web -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<application
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import android.util.Log
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
@@ -31,7 +32,10 @@ object ServiceManager {
start(context)
}
@Synchronized
fun start(context: Context) {
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
// Resets Proxy Use
@@ -75,6 +79,8 @@ object ServiceManager {
}
fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
@@ -19,6 +19,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -89,10 +89,6 @@ class Account(
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -567,16 +563,15 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
recipientPubKey = toUser.pubkey(),
publishedRecipientPubKey = toUser.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
@@ -1135,7 +1130,9 @@ class Account(
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
GlobalScope.launch(Dispatchers.IO) {
updateContactListTo(userProfile().latestContactList)
}
}
// imports transient blocks due to spam.
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -16,11 +18,18 @@ class AntiSpamFilter {
@Synchronized
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
val idHex = event.id
// if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false
// if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -28,6 +37,7 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
@@ -11,11 +11,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -31,6 +32,8 @@ object LocalCache {
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
}
@@ -39,6 +42,8 @@ object LocalCache {
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
@@ -47,6 +52,8 @@ object LocalCache {
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
@@ -58,6 +65,8 @@ object LocalCache {
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
@@ -66,6 +75,8 @@ object LocalCache {
}
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
}
@@ -84,6 +95,8 @@ object LocalCache {
@Synchronized
fun getOrCreateChannel(key: String): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
channels.put(key, answer)
@@ -106,17 +119,27 @@ object LocalCache {
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
val answer = AddressableNote(key)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toTag(), answer)
answer
}
}
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
}
return note
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
@@ -268,12 +291,26 @@ object LocalCache {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
if (note.event?.id() == event.id()) return
note.loadEvent(event, author, emptyList())
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AudioTrackEvent) {
@@ -281,11 +318,13 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
if (note.event?.id() == event.id()) return
note.loadEvent(event, author, emptyList())
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) {
@@ -357,11 +396,13 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
if (note.event?.id() == event.id()) return
note.loadEvent(event, author, emptyList())
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
refreshObservers(note)
}
}
@Suppress("UNUSED_PARAMETER")
@@ -845,6 +886,8 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
@@ -859,6 +902,8 @@ object LocalCache {
}
fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
@@ -884,6 +929,8 @@ object LocalCache {
}
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
@@ -912,6 +959,8 @@ object LocalCache {
}
fun pruneOldAndHiddenMessages(account: Account) {
checkNotInMainThread()
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
@@ -930,6 +979,8 @@ object LocalCache {
}
fun pruneHiddenMessages(account: Account) {
checkNotInMainThread()
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
}.flatten()
@@ -957,6 +1008,8 @@ object LocalCache {
}
fun pruneContactLists(userAccount: Account) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
@@ -975,8 +1028,17 @@ object LocalCache {
live.invalidateData(newNote)
}
fun consume(event: Event, relay: Relay?) {
if (!event.hasValidSignature()) return
fun verifyAndConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
}
try {
when (event) {
@@ -1001,7 +1063,7 @@ object LocalCache {
is HighlightEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -1015,10 +1077,11 @@ object LocalCache {
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -179,6 +180,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
@@ -190,6 +192,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.relays.FeedType
@Immutable
data class RelaySetupInfo(
val url: String,
val read: Boolean,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -35,6 +36,8 @@ class ThreadAssembler {
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
@@ -11,6 +12,8 @@ object UrlCachedPreviewer {
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
checkNotInMainThread()
cache[url]?.let {
callback?.onComplete(it)
return
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -185,6 +186,8 @@ class User(val pubkeyHex: String) {
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -194,6 +197,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun addMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
@@ -203,6 +208,8 @@ class User(val pubkeyHex: String) {
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
@@ -355,6 +362,7 @@ class UserLiveSet(u: User) {
}
}
@Immutable
data class RelayInfo(
val url: String,
var lastEvent: Long,
@@ -396,7 +404,7 @@ class UserMetadata {
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
.any { it.contains(prefix, true) }
}
fun lnAddress(): String? {
@@ -20,6 +20,8 @@ class BlurHashFetcher(
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service
import android.os.Looper
import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() {
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
}
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
class OnMainThreadException(str: String) : RuntimeException(str)
@@ -33,6 +33,8 @@ class Nip05Verifier() {
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
@@ -70,6 +72,9 @@ class Nip05Verifier() {
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
// checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
@@ -2,7 +2,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.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -12,9 +11,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(account: Account, channelId: String) {
fun loadMessagesBetween(account: Account, channel: Channel) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
this.channel = channel
resetFilters()
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = LocalCache.users[userId]
withUser = user
resetFilters()
}
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.consume(event, relay)
LocalCache.verifyAndConsume(event, relay)
}
}
@@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFiltersSuspend() {
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
@@ -48,14 +48,14 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 20
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
search = mySearchString,
limit = 20
limit = 100
)
)
)
@@ -114,17 +114,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
ids = interestedEvents.toList()
)
)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -45,6 +46,8 @@ class LightningAddressResolver() {
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -98,7 +99,7 @@ object LnInvoiceUtil {
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
private fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
@@ -121,6 +122,8 @@ object LnInvoiceUtil {
}
fun getAmountInSats(invoice: String): BigDecimal {
checkNotInMainThread()
return getAmount(invoice).multiply(BigDecimal(100000000))
}
@@ -138,7 +138,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
@@ -212,7 +212,6 @@ open class Event(
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
@@ -259,6 +258,7 @@ open class Event(
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -283,7 +283,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.nio.charset.Charset
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
@@ -133,7 +134,7 @@ class LnZapRequestEvent(
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return sha256.digest(strbyte)
return MessageDigest.getInstance("SHA-256").digest(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
@@ -0,0 +1,44 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelaySetEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
fun description() = tags.firstOrNull() { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30022
fun create(
relays: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): RelaySetEvent {
val tags = mutableListOf<List<String>>()
relays.forEach {
tags.add(listOf("r", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return RelaySetEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList()
return (
zaps
.filter { it.value != null }
.toList()
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
.mapNotNull { entry ->
entry.value?.let {
ZapReqResponse(entry.key, it)
}
}
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
.reversed()
) as List<Pair<Note, Note>>
)
}
}
@@ -13,15 +13,24 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
// adds to database
LocalCache.verifyAndConsume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
}
}
@@ -54,6 +63,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
@@ -18,6 +19,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -32,6 +33,8 @@ object Client : RelayPool.Listener {
@Synchronized
fun connect(relays: Array<Relay>) {
checkNotInMainThread()
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
@@ -54,6 +57,8 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
@@ -62,11 +67,15 @@ object Client : RelayPool.Listener {
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<TypedFilter> = listOf()
) {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
checkNotInMainThread()
if (relay == null) {
RelayPool.send(signedEvent)
} else {
@@ -91,7 +100,7 @@ object Client : RelayPool.Listener {
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
@@ -69,7 +70,10 @@ class Relay(
@Synchronized
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
checkNotInMainThread()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
@@ -79,6 +83,7 @@ class Relay(
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
checkNotInMainThread()
if (socket != null) return
try {
@@ -89,6 +94,8 @@ class Relay(
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
afterEOSE = false
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
@@ -99,6 +106,8 @@ class Relay(
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
@@ -153,6 +162,8 @@ class Relay(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
@@ -163,6 +174,8 @@ class Relay(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
socket = null
isReady = false
afterEOSE = false
@@ -171,6 +184,8 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
errorCounter++
socket?.close(1000, "Normal close")
@@ -209,6 +224,8 @@ class Relay(
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
@@ -233,6 +250,8 @@ class Relay(
}
fun sendFilterOnlyIfDisconnected() {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
@@ -243,6 +262,8 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
@@ -48,6 +49,8 @@ object RelayPool : Relay.Listener {
}
fun requestAndWatch() {
checkNotInMainThread()
relays.forEach { it.requestAndWatch() }
}
@@ -55,6 +55,9 @@ class TypedFilter(
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
filter.search?.run {
jsonObject.addProperty("search", filter.search)
}
return jsonObject
}
}
@@ -2,7 +2,12 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
@@ -56,6 +61,15 @@ class MainActivity : FragmentActivity() {
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
Client.lenient = true
}
@@ -86,7 +100,40 @@ class MainActivity : FragmentActivity() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
ServiceManager.cleanUp()
GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp()
}
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
override fun onAvailable(network: Network) {
super.onAvailable(network)
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
ServiceManager.start(this@MainActivity)
}
}
// Network capabilities have changed for the network
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
}
// lost network connection
override fun onLost(network: Network) {
super.onLost(network)
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.pause()
}
}
}
}
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
@@ -22,19 +23,24 @@ object ImageUploader {
fun uploadImage(
uri: Uri,
contentType: String?,
size: Long?,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val myContentType = contentType ?: contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
val length = size
?: contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: kotlin.runCatching {
uri.toFile().length()
}.getOrNull() ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
@@ -57,7 +63,7 @@ object ImageUploader {
}
}
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
uploadImage(imageInputStream, length, myContentType, myServer, onSuccess, onError)
}
fun uploadImage(
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Divider
@@ -28,6 +29,8 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@@ -55,6 +58,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -73,13 +77,27 @@ import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.account
val searchBarViewModel: SearchBarViewModel = viewModel(
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
factory = SearchBarViewModel.Factory(
accountViewModel.account
)
)
JoinUserOrChannelView(
searchBarViewModel = searchBarViewModel,
onClose = onClose,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clean()
searchBarViewModel.clear()
onClose()
},
properties = DialogProperties(
@@ -88,7 +106,9 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
modifier = Modifier
.padding(10.dp)
.heightIn(min = 500.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -96,7 +116,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
searchBarViewModel.clean()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
})
@@ -115,28 +135,20 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, accountViewModel, nav)
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RenderSeach(
private fun RenderSearch(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onlineSearch = NostrSearchEventOrUserDataSource
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
@@ -147,7 +159,8 @@ private fun RenderSeach(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData()
}
}
@@ -155,9 +168,6 @@ private fun RenderSeach(
}
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges.receiveAsFlow()
@@ -166,13 +176,13 @@ private fun RenderSeach(
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) {
launch(Dispatchers.Main) {
listState.animateScrollToItem(0)
}
}
@@ -200,8 +210,34 @@ private fun RenderSeach(
}
// LAST ROW
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SearchEditTextForJoin(
searchBarViewModel: SearchBarViewModel,
searchTextChanges: Channel<String>
) {
val scope = rememberCoroutineScope()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(100)
focusRequester.requestFocus()
}
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
modifier = Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -238,11 +274,11 @@ private fun RenderSeach(
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
}
) {
Icon(
@@ -254,10 +290,24 @@ private fun RenderSeach(
}
)
}
}
@Composable
private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
if (searchBarViewModel.searchValue.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -268,50 +318,60 @@ private fun RenderSeach(
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResultsUsers.value,
users,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
accountViewModel = accountViewModel,
nav = nav
)
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
searchBarViewModel.clear()
}
}
itemsIndexed(
searchBarViewModel.searchResultsChannels.value,
channels,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { nav("Channel/${item.idHex}") }
)
RenderChannel(item) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
}
}
}
}
}
@Composable
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
onClick: () -> Unit
) {
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = onClick
)
}
@Composable
fun UserComposeForChat(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
onClick: () -> Unit
) {
Column(
modifier =
Modifier.clickable(
onClick = { nav("Room/${baseUser.pubkeyHex}") }
onClick = onClick
)
) {
Row(
@@ -323,22 +383,18 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, accountViewModel, 55.dp)
UserPicture(baseUser, 55.dp, accountViewModel)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
val baseUserState by baseUser.live().metadata.observeAsState()
val user = baseUserState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
DisplayUserAboutInfo(baseUser)
}
}
@@ -348,3 +404,20 @@ fun UserComposeForChat(
)
}
}
@Composable
private fun DisplayUserAboutInfo(baseUser: User) {
val baseUserState by baseUser.live().metadata.observeAsState()
val about by remember(baseUserState) {
derivedStateOf {
baseUserState?.user?.info?.about ?: ""
}
}
Text(
text = about,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -26,6 +27,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
@@ -53,10 +56,14 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
onClose()
})
val scope = rememberCoroutineScope()
PostButton(
onPost = {
postViewModel.create()
onClose()
scope.launch(Dispatchers.IO) {
postViewModel.create()
onClose()
}
},
postViewModel.channelName.value.text.isNotBlank()
)
@@ -100,7 +107,9 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
OutlinedTextField(
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -10,10 +11,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -54,33 +57,56 @@ open class NewMediaModel : ViewModel() {
isUploadingImage = true
val contentResolver = context.contentResolver
val uri = galleryUri ?: return
val myGalleryUri = galleryUri ?: return
val serverToUse = selectedServer ?: return
if (selectedServer == ServersAvailable.NIP95) {
val contentType = contentResolver.getType(myGalleryUri)
viewModelScope.launch(Dispatchers.IO) {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Loading"
val contentType = contentResolver.getType(uri)
contentResolver.openInputStream(uri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
uploadingDescription.value = "Compress"
MediaCompressor().compress(
myGalleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (selectedServer == ServersAvailable.NIP95) {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
?: run {
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
}
}
} else {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
uploadingPercentage.value = 0.00f
uploadingDescription.value = null
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
} else {
uploadingPercentage.value = 0.1f
uploadingDescription.value = "Uploading"
ImageUploader.uploadImage(
uri = uri,
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
@@ -162,7 +188,7 @@ open class NewMediaModel : ViewModel() {
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
uploadingPercentage.value = 0.20f
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
viewModelScope.launch(Dispatchers.IO) {
@@ -173,7 +199,7 @@ open class NewMediaModel : ViewModel() {
description,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.30f
uploadingPercentage.value = 0.40f
val nip95 = account?.createNip95(bytes, headerInfo = it)
if (nip95 != null) {
@@ -33,8 +33,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -47,8 +49,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -124,8 +131,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -172,7 +179,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
VideoView(it.toString())
}
}
}
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -70,9 +71,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -96,8 +102,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -143,8 +153,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
onClose()
}
},
isActive = postViewModel.canPost()
)
@@ -160,7 +172,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scrollState)
) {
Notifying(postViewModel.mentions) {
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
@@ -278,11 +290,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
QuoteBorder
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
@@ -291,10 +303,15 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
}
BechLink(
myUrlPreview,
true,
MaterialTheme.colors.background,
backgroundColor,
accountViewModel,
nav
)
@@ -370,7 +387,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
@@ -383,13 +400,15 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
innerUserState?.user?.let { myUser ->
Spacer(modifier = Modifier.width(5.dp))
val tags = remember(innerUserState) {
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
Button(
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
),
@@ -398,8 +417,8 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
}
) {
CreateTextWithEmoji(
text = "${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
text = remember(innerUserState) { "${myUser.toBestDisplayName()}" },
tags = tags,
color = Color.White,
textAlign = TextAlign.Center
)
@@ -607,7 +626,7 @@ fun CloseButton(onCancel: () -> Unit) {
onClick = {
onCancel()
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -631,7 +650,7 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -650,7 +669,7 @@ fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -669,7 +688,7 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -688,7 +707,7 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
onPost()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -742,8 +761,8 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
@@ -752,11 +771,11 @@ fun ImageVideoDescription(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
QuoteBorder
)
) {
Column(
@@ -849,7 +868,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri)
VideoView(uri.toString())
}
}
@@ -907,7 +926,7 @@ fun ImageVideoDescription(
onClick = {
onAdd(message, selectedServer)
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -917,3 +936,10 @@ fun ImageVideoDescription(
}
}
}
@Stable
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
@@ -18,13 +19,15 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
open class NewPostViewModel : ViewModel() {
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var originalNote: Note? = null
@@ -132,7 +135,7 @@ open class NewPostViewModel : ViewModel() {
} else if (originalNote?.channelHex() != null) {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
}
@@ -140,35 +143,52 @@ open class NewPostViewModel : ViewModel() {
cancel()
}
fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) {
isUploadingImage = true
contentToAddUrl = null
val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
if (server == ServersAvailable.NIP95) {
val contentType = contentResolver.getType(it)
contentResolver.openInputStream(it)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
} else {
ImageUploader.uploadImage(
uri = it,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
viewModelScope.launch(Dispatchers.IO) {
MediaCompressor().compress(
galleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
}
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = server,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
imageUploadingError.emit(it)
}
}
)
@@ -226,7 +246,11 @@ open class NewPostViewModel : ViewModel() {
userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
@@ -242,7 +266,15 @@ open class NewPostViewModel : ViewModel() {
userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(
compareBy(
{ account?.isFollowing(it) },
{ it.toBestDisplayName() }
)
).reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -52,6 +51,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import java.lang.Math.round
@Composable
@@ -504,7 +504,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Uni
read = true
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
@@ -1,15 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NewRelayListViewModel : ViewModel() {
private lateinit var account: Account
@@ -24,7 +28,9 @@ class NewRelayListViewModel : ViewModel() {
fun create() {
relays.let {
account.saveRelayList(it.value)
viewModelScope.launch(Dispatchers.IO) {
account.saveRelayList(it.value)
}
}
clear()
@@ -46,7 +52,7 @@ class NewRelayListViewModel : ViewModel() {
if (relayFile != null) {
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
@@ -28,6 +28,9 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
@@ -37,8 +40,12 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -13,6 +13,9 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import id.zelory.compressor.Compressor.compress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
@@ -119,7 +122,9 @@ class NewUserMetadataViewModel : ViewModel() {
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
}
clear()
}
@@ -140,46 +145,67 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun uploadForPicture(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForPicture = it
},
onUploaded = {
picture.value = it
}
)
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = {
isUploadingImageForPicture = it
},
onUploaded = {
picture.value = it
}
)
}
}
fun uploadForBanner(uri: Uri, context: Context) {
upload(
uri,
context,
onUploading = {
isUploadingImageForBanner = it
},
onUploaded = {
banner.value = it
}
)
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = {
isUploadingImageForBanner = it
},
onUploaded = {
banner.value = it
}
)
}
}
fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
private suspend fun upload(galleryUri: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
onUploading(true)
ImageUploader.uploadImage(
uri = it,
server = account.defaultFileServer,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
val contentResolver = context.contentResolver
MediaCompressor().compress(
galleryUri,
contentResolver.getType(galleryUri),
context.applicationContext,
onReady = { fileUri, contentType, size ->
ImageUploader.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
server = account.defaultFileServer,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
},
onError = {
onUploading(false)
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
imageUploadingError.emit(it)
}
}
)
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
@@ -12,11 +11,11 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.coroutines.launch
import java.io.File
@@ -75,7 +74,7 @@ fun SaveToGallery(url: String) {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -135,7 +134,7 @@ fun SaveToGallery(localFile: File, mimeType: String?) {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
@@ -28,6 +28,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@@ -99,21 +101,23 @@ private fun UploadBoxButton(
}
}
val DefaultAnimationColors = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: List<Color> = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
),
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000
) {
val infiniteTransition = rememberInfiniteTransition()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -58,6 +59,8 @@ class BundledInsert<T>(
private var queue = LinkedBlockingQueue<T>()
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
return
@@ -13,6 +13,7 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -46,8 +47,11 @@ import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -58,18 +62,46 @@ fun ClickableRoute(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
if (nip19.type == Nip19.Type.USER) {
DisplayUser(nip19, nav)
} else if (nip19.type == Nip19.Type.ADDRESS) {
DisplayAddress(nip19, nav)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, nav)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, nav)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, nav)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, nav)
}
else -> {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
}
@Composable
private fun LoadNote(
hex: String,
content: @Composable (Note) -> Unit
) {
var noteBase by remember(hex) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
}
}
noteBase?.let {
content(it)
}
}
@@ -78,66 +110,8 @@ private fun DisplayEvent(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
nav = nav
)
}
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
)
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
@@ -146,63 +120,61 @@ private fun DisplayNote(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = note.channelHex()
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
@Composable
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
nav: (String) -> Unit
) {
val noteState by it.live().metadata.observeAsState()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
}
@@ -222,19 +194,24 @@ private fun DisplayAddress(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val route = remember(noteState) { "Note/${nip19.hex}" }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
clickablePart = displayName,
suffix = addedCharts,
route = route,
nav = nav
)
}
if (noteBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -256,12 +233,15 @@ private fun DisplayUser(
val userState by it.live().metadata.observeAsState()
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val addedCharts = remember {
"${nip19.additionalChars} "
}
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "${nip19.additionalChars} ",
suffix = addedCharts,
tags = userTags,
route = route,
nav = nav
@@ -271,7 +251,9 @@ private fun DisplayUser(
if (userBase == null) {
Text(
"@${nip19.hex}${nip19.additionalChars} "
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
@@ -305,7 +287,7 @@ fun CreateClickableText(
@Composable
fun CreateTextWithEmoji(
text: String,
tags: List<List<String>>?,
tags: ImmutableListOfLists<String>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
@@ -319,7 +301,7 @@ fun CreateTextWithEmoji(
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(text, emojis)
@@ -364,7 +346,7 @@ fun CreateTextWithEmoji(
@Composable
fun CreateTextWithEmoji(
text: String,
emojis: Map<String, String>,
emojis: ImmutableMap<String, String>,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
@@ -420,7 +402,7 @@ fun CreateTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: List<List<String>>?,
tags: ImmutableListOfLists<String>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
@@ -429,7 +411,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
@@ -457,7 +439,7 @@ fun CreateClickableTextWithEmoji(
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: List<List<String>>?,
tags: ImmutableListOfLists<String>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
@@ -470,7 +452,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
@@ -494,7 +476,7 @@ fun CreateClickableTextWithEmoji(
}
}
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
return NIP30Parser().buildArray(text).map {
val url = emojis[it]
if (url != null) {
@@ -502,15 +484,20 @@ suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): Li
} else {
TextType(it)
}
}
}.toImmutableList()
}
@Immutable
open class Renderable()
@Immutable
class TextType(val text: String) : Renderable()
@Immutable
class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
@@ -571,7 +558,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
@Composable
fun InLineIconRenderer(
wordsInOrder: List<Renderable>,
wordsInOrder: ImmutableList<Renderable>,
style: SpanStyle,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
@@ -4,6 +4,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@@ -11,10 +12,16 @@ import androidx.compose.ui.text.AnnotatedString
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
val doubleCheckedUrl = remember(url) {
if (url.contains("://")) url else "https://$url"
}
val text = remember(urlText) {
AnnotatedString(urlText)
}
ClickableText(
text = AnnotatedString(urlText),
text = text,
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -6,6 +6,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.model.User
@@ -14,10 +15,19 @@ fun ClickableUserTag(
user: User,
nav: (String) -> Unit
) {
val route = remember {
"User/${user.pubkeyHex}"
}
val innerUserState by user.live().metadata.observeAsState()
val userName = remember(innerUserState) {
AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}")
}
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") },
text = userName,
onClick = { nav(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
@@ -13,14 +13,14 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun MayBeWithdrawal(lnurlWord: String) {
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lnurlWord) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
}
}
@@ -38,11 +38,19 @@ fun MayBeWithdrawal(lnurlWord: String) {
fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ")
}
ClickableText(
text = AnnotatedString("$withdrawalString "),
text = withdraw,
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
val intent = Intent(Intent.ACTION_VIEW, uri)
ContextCompat.startActivity(context, intent, null)
}
},
@@ -7,12 +7,12 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -20,13 +20,16 @@ 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.drawBehind
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
const val SHORT_TEXT_LENGTH = 350
@@ -34,9 +37,9 @@ const val SHORT_TEXT_LENGTH = 350
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
backgroundColor: Color,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -78,27 +81,37 @@ fun ExpandableRichTextViewer(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
.drawBehind {
drawRect(
Brush.verticalGradient(
colors = listOf(
backgroundColor.value.copy(alpha = 0f),
backgroundColor.value
)
)
)
)
}
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
ShowMoreButton() {
showFullText = !showFullText
}
}
}
}
}
@Composable
public fun ShowMoreButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -25,21 +24,21 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.text.NumberFormat
@Composable
fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
e.printStackTrace()
null
@@ -60,15 +59,15 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), QuoteBorder)
) {
Column(
modifier = Modifier
@@ -100,9 +99,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
amount?.let {
Text(
text = "${
NumberFormat.getInstance().format(amount)
} ${stringResource(id = R.string.sats)}",
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
@@ -121,7 +118,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
startActivity(context, intent, null)
}
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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
@@ -37,6 +36,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlinx.coroutines.launch
@Composable
@@ -56,8 +56,8 @@ fun InvoiceRequest(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), QuoteBorder)
) {
Column(
modifier = Modifier
@@ -153,7 +153,7 @@ fun InvoiceRequest(
}
)
},
shape = RoundedCornerShape(15.dp),
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -0,0 +1,110 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
class MediaCompressor {
suspend fun compress(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (String) -> Unit
) {
if (contentType?.startsWith("video", true) == true) {
VideoCompressor.start(
context = applicationContext, // => This is required
uris = listOf(uri), // => Source can be provided as content uris
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(
videoName = UUID.randomUUID().toString() // => required name
),
configureWith = Configuration(
quality = VideoQuality.LOW
),
listener = object : CompressionListener {
override fun onProgress(index: Int, percent: Float) {
}
override fun onStart(index: Int) {
// Compression start
}
override fun onSuccess(index: Int, size: Long, path: String?) {
if (path != null) {
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
onError("Compression Returned null")
}
}
override fun onFailure(index: Int, failureMessage: String) {
onError(failureMessage)
}
override fun onCancelled(index: Int) {
onError("Compression Cancelled")
}
}
)
} else if (contentType?.startsWith("image", true) == true && !contentType.contains("gif")) {
val compressedImageFile = Compressor.compress(applicationContext, from(uri, contentType, applicationContext)) {
default(width = 640, format = Bitmap.CompressFormat.JPEG)
}
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} else {
onReady(uri, contentType, null)
}
}
fun from(uri: Uri?, contentType: String?, context: Context): File {
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val inputStream = context.contentResolver.openInputStream(uri!!)
val fileName: String = UUID.randomUUID().toString() + ".$extension"
val splitName: Array<String> = splitFileName(fileName)
val tempFile = File.createTempFile(splitName[0], splitName[1])
inputStream?.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(1024 * 50)
var read: Int = input.read(buffer)
while (read != -1) {
output.write(buffer, 0, read)
read = input.read(buffer)
}
}
}
return tempFile
}
private fun splitFileName(fileName: String): Array<String> {
var name = fileName
var extension = ""
val i = fileName.lastIndexOf(".")
if (i != -1) {
name = fileName.substring(0, i)
extension = fileName.substring(i)
}
return arrayOf(name, extension)
}
}
@@ -6,7 +6,6 @@ import android.util.Patterns
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
@@ -39,8 +38,11 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -57,7 +59,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -97,9 +98,9 @@ fun isMarkdown(content: String): Boolean {
fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>,
backgroundColor: Color,
modifier: Modifier,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -122,13 +123,13 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>,
tags: ImmutableListOfLists<String>,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -142,34 +143,53 @@ private fun RenderRegular(
}
}
val paragraphs = remember(content) {
content.split('\n').toImmutableList()
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed()
} else {
paragraph.trim().split(' ')
}
}
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
paragraphs.forEach { paragraph ->
RenderParagraph(paragraph, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderParagraph(
paragraph: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>
) {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed().toImmutableList()
} else {
paragraph.trim().split(' ').toImmutableList()
}
}
FlowRow() {
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
}
}
private fun parseUrls(
content: String,
tags: List<List<String>>
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
@@ -186,7 +206,7 @@ private fun parseUrls(
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
RichTextViewerState(
@@ -214,10 +234,10 @@ private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: List<List<String>>
tags: ImmutableListOfLists<String>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
@@ -238,7 +258,7 @@ private fun RenderWord(
WordType.BECH
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches()) {
if (tags.isNotEmpty()) {
if (tags.lists.isNotEmpty()) {
WordType.HASH_INDEX
} else {
WordType.OTHER
@@ -267,8 +287,8 @@ private fun RenderWordWithoutPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -300,8 +320,8 @@ private fun RenderWordWithPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -382,7 +402,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List<List<String>>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
@@ -392,16 +412,16 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
QuoteBorder
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
.compositeOver(backgroundColor.value)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
@@ -412,7 +432,7 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
.compositeOver(backgroundColor)
.compositeOver(backgroundColor.value)
)
)
)
@@ -442,17 +462,13 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
@Composable
private fun RefreshableContent(content: String, tags: List<List<String>>?, onCompose: @Composable (String) -> Unit) {
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, onCompose: @Composable (String) -> Unit) {
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
val scope = rememberCoroutineScope()
ObserverAllNIP19References(content, tags) {
scope.launch(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
@@ -462,7 +478,7 @@ private fun RefreshableContent(content: String, tags: List<List<String>>?, onCom
}
@Composable
fun ObserverAllNIP19References(content: String, tags: List<List<String>>?, onRefresh: () -> Unit) {
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
@@ -490,7 +506,7 @@ fun ObserveNIP19(
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
onRefresh: suspend () -> Unit
onRefresh: () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
@@ -551,7 +567,7 @@ private fun ObserveNIP19User(
}
}
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
@@ -561,8 +577,8 @@ private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
@@ -602,7 +618,7 @@ private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>?
return null
}
private fun returnNIP19References(content: String, tags: List<List<String>>?): List<Nip19.Return> {
private fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -615,7 +631,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
}
}
tags?.forEach {
tags?.lists?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
@@ -628,7 +644,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<String>>?): String {
private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -702,55 +718,90 @@ fun startsWithNIP19Scheme(word: String): Boolean {
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Immutable
data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
@Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
LaunchedEffect(key1 = word) {
launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
var returningNote: Note? = null
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars)
returningNote = note
}
}
nip19Route = it
loadedLink = LoadedBechLink(returningNote, it)
}
}
}
if (canPreview) {
baseNotePair?.let {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
if (!it.second.isNullOrEmpty()) {
Text(
"${it.second} "
)
loadedLink?.let { loadedLink ->
loadedLink.baseNote?.let {
DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink)
} ?: run {
ClickableRoute(loadedLink.nip19, nav)
}
} ?: nip19Route?.let {
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
} ?: run {
Text(text = remember { "$word " })
}
} else {
nip19Route?.let {
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
loadedLink?.let {
ClickableRoute(it.nip19, nav)
} ?: run {
Text(text = remember { "$word " })
}
}
}
@Composable
private fun DisplayFullNote(
it: Note,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
loadedLink: LoadedBechLink
) {
val borderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
val modifier = remember {
Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
borderColor,
QuoteBorder
)
}
NoteCompose(
baseNote = it,
accountViewModel = accountViewModel,
modifier = modifier,
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
val extraChars = remember(loadedLink) {
if (loadedLink.nip19.additionalChars.isNotBlank()) {
"${loadedLink.nip19.additionalChars} "
} else {
null
}
}
extraChars?.let {
Text(
it
)
}
}
@@ -831,13 +882,14 @@ fun HashTag(word: String, nav: (String) -> Unit) {
} ?: Text(text = "$word ")
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedTag by remember { mutableStateOf<LoadedTag?>(null) }
LaunchedEffect(key1 = word) {
if (baseUserPair == null && baseNotePair == null) {
if (loadedTag == null) {
launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
@@ -845,20 +897,20 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
Pair(null, "")
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
loadedTag = LoadedTag(it, null, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
loadedTag = LoadedTag(null, it, suffix)
}
}
}
@@ -867,55 +919,80 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
}
baseUserPair?.let {
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = "${it.second} ",
tags = userTags,
route = route,
nav = nav
)
}
baseNotePair?.let {
if (canPreview) {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
if (loadedTag == null) {
Text(
text = remember {
"$word "
}
} else {
ClickableNoteTag(it.first, nav)
Text(text = "${it.second} ")
)
} else {
loadedTag?.user?.let {
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ")
loadedTag?.note?.let {
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
}
}
}
@Composable
private fun DisplayNoteFromTag(
baseNote: Note,
addedChars: String,
canPreview: Boolean,
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit
) {
if (canPreview) {
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
QuoteBorder
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
addedChars.ifBlank { null }?.let {
Text(text = remember { "$it " })
}
} else {
ClickableNoteTag(baseNote, nav)
Text(text = remember { "$addedChars " })
}
}
@Composable
private fun DisplayUserFromTag(
baseUser: User,
addedChars: String,
nav: (String) -> Unit
) {
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${baseUser.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "$addedChars " },
tags = userTags,
route = route,
nav = nav
)
}
@@ -12,6 +12,7 @@ import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.Buffer
import java.security.MessageDigest
@@ -21,8 +22,6 @@ private fun toHex(color: Color): String {
return String.format("#%06X", rgb)
}
private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
private fun byteMod10(byte: Byte): Int {
val ub = byte.toUByte().toInt()
return ub % 10
@@ -33,7 +32,7 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
}
private fun svgString(msg: String): String {
val hash = sha256.digest(msg.toByteArray())
val hash = MessageDigest.getInstance("SHA-256").digest(msg.toByteArray())
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
@@ -67,6 +66,7 @@ class HashImageFetcher(
) : Fetcher {
override suspend fun fetch(): SourceResult {
checkNotInMainThread()
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
@@ -10,7 +10,6 @@ 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.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
@@ -35,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@Composable
fun SensitivityWarning(
@@ -108,7 +108,7 @@ fun ContentWarningNote(onDismiss: () -> Unit) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onDismiss,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -31,13 +31,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TextSpinner(
label: String,
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -83,7 +84,12 @@ fun TextSpinner(
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(
options: ImmutableList<String>,
explainers: ImmutableList<String>?,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
@Composable
fun UrlPreviewCard(
@@ -32,11 +32,11 @@ fun UrlPreviewCard(
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
QuoteBorder
)
) {
Column {
@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -27,6 +25,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,7 +44,6 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
@@ -60,26 +58,9 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
@@ -105,79 +86,109 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) {
if (loadingFinished.second != null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
} else {
VideoView(videoUri, description, onDialog)
VideoView(videoUri, description, null, onDialog)
}
}
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun VideoView(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
val defaultVolume = remember { if (DefaultMutedSetting.value) 0f else 1f }
LaunchedEffect(key1 = videoUri) {
if (exoPlayer == null) {
if (exoPlayerData == null) {
launch(Dispatchers.Default) {
exoPlayer = ExoPlayer.Builder(context).build()
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
}
}
}
exoPlayer?.let {
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
exoPlayerData?.let {
VideoView(videoUri, description, it, thumb, onDialog)
}
it.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = defaultVolume
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
DisposableEffect(Unit) {
onDispose {
exoPlayerData?.exoPlayer?.release()
}
}
}
@Composable
fun VideoView(
videoUri: String,
description: String? = null,
exoPlayerData: VideoPlayer,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val defaultVolume = remember { if (DefaultMutedSetting.value) 0f else 1f }
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
exoPlayerData.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = defaultVolume
if (videoUri.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
}
prepare()
)
}
RenderVideoPlayer(it, context, thumb, onDialog)
prepare()
}
RenderVideoPlayer(exoPlayerData, thumb, onDialog)
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer?.pause()
exoPlayerData.exoPlayer.pause()
}
else -> {}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
lifecycle.addObserver(observer)
onDispose {
exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Stable
data class VideoPlayer(
val exoPlayer: ExoPlayer
)
@Stable
data class VideoThumb(
val thumb: Drawable?
)
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
playerData: VideoPlayer,
thumbData: VideoThumb?,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
BoxWithConstraints() {
AndroidView(
modifier = Modifier
@@ -185,27 +196,27 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
player = playerData.exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
playerData.exoPlayer.pause()
innerOnDialog(it)
}
}
@@ -216,7 +227,7 @@ private fun RenderVideoPlayer(
MuteButton() { mute: Boolean ->
DefaultMutedSetting.value = mute
exoPlayer.volume = if (mute) 0f else 1f
playerData.exoPlayer.volume = if (mute) 0f else 1f
}
}
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
@@ -64,6 +63,7 @@ import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.imageLoader
@@ -75,6 +75,9 @@ import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -164,7 +167,7 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -174,11 +177,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
QuoteBorder
)
if (content is ZoomableUrlContent) {
@@ -201,7 +204,10 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
}
}
if (dialogOpen) {
@@ -366,10 +372,9 @@ private fun aspectRatio(dim: String?): Float? {
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
delay(200)
cnt = content
}
@@ -442,7 +447,7 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -507,7 +512,9 @@ fun RenderImageOrVideo(content: ZoomableContent) {
LocalImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
content.localFile?.let {
VideoView(it.toUri().toString(), content.description)
}
}
}
}
@@ -1,23 +1,12 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var channelId: String? = null
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
this.account = accountLoggedIn
this.channelId = channelId
}
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingChannel = channelId ?: return emptyList()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return channel.notes
.values
.filter { account.isAcceptable(it) }
@@ -26,9 +15,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingChannel = channelId ?: return emptySet()
val channel = LocalCache.getOrCreateChannel(processingChannel)
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
@@ -1,51 +1,29 @@
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 : AdditiveFeedFilter<Note>() {
var account: Account? = null
var withUser: String? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = userId
}
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
val processingUser = withUser ?: return emptyList()
val myAccount = account
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptyList()
val messages = myAccount
val messages = account
.userProfile()
.privateChatrooms[myUser] ?: return emptyList()
.privateChatrooms[withUser] ?: return emptyList()
return messages.roomMessages
.filter { myAccount.isAcceptable(it) }
.filter { account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val processingUser = withUser ?: return emptySet()
val myAccount = account
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
if (myAccount == null || myUser == null) return emptySet()
val messages = myAccount
val messages = account
.userProfile()
.privateChatrooms[myUser] ?: return emptySet()
.privateChatrooms[withUser] ?: return emptySet()
return collection
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
.filter { it in messages.roomMessages && account.isAcceptable(it) == true }
.toSet()
}
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -84,7 +83,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
sort(myNewList.toSet()).take(1000)
}
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
@@ -1,9 +1,14 @@
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.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -26,4 +31,80 @@ class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
sort(myNewList.toSet()).take(1000)
}
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty()) {
emptySet()
} else {
newRelevantPrivateMessages.values.toSet()
}
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
if (roomUserHex != null &&
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
!account.isHidden(roomUserHex)
) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -1,12 +1,15 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
checkNotInMainThread()
val (feed, elapsed) = measureTimedValue {
feed()
}
@@ -24,6 +27,8 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread()
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
@@ -34,7 +39,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
}
}
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
}
@@ -1,9 +1,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feed() = account.hiddenUsers()
override fun feed(): List<User> {
return (account.hiddenUsers + account.transientHiddenUsers)
.map { LocalCache.getOrCreateUser(it) }
}
}
@@ -1,17 +1,11 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
var user: User? = null
fun loadUserProfile(user: User?) {
this.user = user
}
override fun feed(): List<Pair<Note, Note>> {
return UserZaps.forProfileFeed(user?.zaps)
class UserProfileZapsFeedFilter(val user: User) : FeedFilter<ZapReqResponse>() {
override fun feed(): List<ZapReqResponse> {
return UserZaps.forProfileFeed(user.zaps)
}
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -49,6 +50,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -143,27 +145,21 @@ fun DisplayAccount(
baseUser?.let {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().clickable {
accountStateViewModel.switchUser(acc.npub)
}.padding(16.dp, 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.weight(1f)
.clickable {
accountStateViewModel.switchUser(acc.npub)
},
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.padding(16.dp, 16.dp)
.weight(1f),
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
modifier = Modifier.width(55.dp).padding(0.dp)
) {
AccountPicture(it)
}
@@ -232,14 +228,16 @@ private fun AccountName(
}
val tags by remember(userState) {
derivedStateOf {
user.info?.latestMetadata?.tags
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
displayName?.let {
CreateTextWithEmoji(
text = it,
tags = tags
tags = tags,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -37,8 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -67,11 +67,16 @@ fun keyboardAsState(): State<Keyboard> {
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
if (newKeyboardValue != keyboardState.value) {
keyboardState.value = newKeyboardValue
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
@@ -84,21 +89,30 @@ fun keyboardAsState(): State<Keyboard> {
}
@Composable
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State<NavBackStackEntry?>, nav: (Route, Boolean) -> Unit) {
val isKeyboardOpen by keyboardAsState()
if (isKeyboardOpen == Keyboard.Closed) {
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item, accountViewModel, navController)
}
RenderBottomMenu(accountViewModel, navEntryState, nav)
}
}
@Composable
private fun RenderBottomMenu(
accountViewModel: AccountViewModel,
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
}
}
}
@@ -108,7 +122,8 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
private fun RowScope.HasNewItemsIcon(
route: Route,
accountViewModel: AccountViewModel,
navController: NavHostController
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
var hasNewItems by remember { mutableStateOf(false) }
@@ -126,23 +141,10 @@ private fun RowScope.HasNewItemsIcon(
iconSize = if ("Home" == route.base) 24.dp else 20.dp,
base = route.base,
hasNewItems = hasNewItems,
navController
navEntryState = navEntryState
) { selected ->
scope.launch {
if (!selected) {
navController.navigate(route.base) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
}
nav(route, selected)
}
}
}
@@ -183,30 +185,40 @@ private fun RowScope.BottomIcon(
iconSize: Dp,
base: String,
hasNewItems: Boolean,
navController: NavHostController,
navEntryState: State<NavBackStackEntry?>,
onClick: (Boolean) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
navBackStackEntry?.let {
val selected = remember(it) {
it.destination.route?.substringBefore("?") == base
val selected by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.destination?.route?.substringBefore("?") == base
}
BottomNavigationItem(
icon = {
NotifiableIcon(
icon,
size,
iconSize,
selected,
hasNewItems
)
},
selected = selected,
onClick = { onClick(selected) }
)
}
NavigationIcon(icon, size, iconSize, selected, hasNewItems, onClick)
}
@Composable
private fun RowScope.NavigationIcon(
icon: Int,
size: Dp,
iconSize: Dp,
selected: Boolean,
hasNewItems: Boolean,
onClick: (Boolean) -> Unit
) {
BottomNavigationItem(
icon = {
NotifiableIcon(
icon,
size,
iconSize,
selected,
hasNewItems
)
},
selected = selected,
onClick = { onClick(selected) }
)
}
@Composable
@@ -130,7 +130,7 @@ fun AppNavigation(
if (scrollToTop) {
notifFeedViewModel.clear()
notifFeedViewModel.sendToTop()
notifFeedViewModel.invalidateDataAndSendToTop()
it.arguments?.remove("scrollToTop")
}
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import coil.Coil
import com.vitorpamplona.amethyst.R
@@ -67,6 +69,7 @@ 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.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
@@ -88,8 +91,29 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@Composable
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)?.substringBefore("?")) {
fun AppTopBar(
followLists: FollowListViewModel,
navEntryState: State<NavBackStackEntry?>,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.destination?.route?.substringBefore("?")
}
}
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel)
}
@Composable
private fun RenderTopRouteBar(
currentRoute: String?,
followLists: FollowListViewModel,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
) {
when (currentRoute) {
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
@@ -228,15 +252,6 @@ private fun RelayStatus(
onClick: () -> Unit
) {
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -247,24 +262,56 @@ private fun RelayStatus(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = connectedRelaysText,
color = if (connectedRelaysLiveData.value == 0) {
Color.Red
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
},
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = onClick
)
)
RelayStatus(relayViewModel, onClick)
}
}
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel,
onClick: () -> Unit
) {
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
val isConnected by remember(connectedRelaysLiveData) {
derivedStateOf {
(connectedRelaysLiveData.value ?: 0) > 0
}
}
RenderRelayStatus(connectedRelaysText, isConnected, onClick)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean,
onClick: () -> Unit
) {
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
} else {
Color.Red
},
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = onClick
)
)
}
@Composable
private fun RelayIcon(onClick: () -> Unit) {
IconButton(
@@ -319,15 +366,15 @@ fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlob
(defaultOptions + followLists)
}
val followNames = remember(followLists) {
val followNames by remember(followLists) {
derivedStateOf {
allLists.map { it.second }
allLists.map { it.second }.toImmutableList()
}
}
SimpleTextSpinner(
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value,
options = followNames,
onSelect = {
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
}
@@ -347,6 +394,8 @@ class FollowListViewModel(val account: Account) : ViewModel() {
}
private suspend fun refreshFollows() {
checkNotInMainThread()
val newFollowLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
@@ -371,6 +420,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
refresh()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (newNotes.any { it.event is PeopleListEvent }) {
refresh()
}
@@ -393,8 +443,8 @@ class FollowListViewModel(val account: Account) : ViewModel() {
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -49,6 +49,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@@ -59,6 +60,7 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -127,7 +129,7 @@ fun ProfileContent(
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture()?.ifBlank { null }?.let { ResizeImage(it, 100.dp) } }
val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.bestDisplayName() }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
Box {
@@ -185,14 +187,18 @@ fun ProfileContent(
}
}),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
fontSize = 18.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (bestUserName != null) {
CreateTextWithEmoji(
text = " @$bestUserName",
tags = accountUser.info?.latestMetadata?.tags,
text = remember { " @$bestUserName" },
tags = tags,
color = Color.LightGray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 15.dp)
.clickable(
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavDestination
@@ -18,12 +19,16 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
val arguments: List<NamedNavArgument> = emptyList()
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
) {
val base: String
get() = route.substringBefore("?")
@@ -34,26 +39,26 @@ sealed class Route(
arguments = listOf(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
),
).toImmutableList(),
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Search : Route(
route = "Search?scrollToTop={scrollToTop}",
icon = R.drawable.ic_globe,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
)
object Video : Route(
route = "Video?scrollToTop={scrollToTop}",
icon = R.drawable.ic_video,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
)
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList(),
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
@@ -76,37 +81,37 @@ sealed class Route(
object Profile : Route(
route = "User/{id}",
icon = R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Note : Route(
route = "Note/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Hashtag : Route(
route = "Hashtag/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Channel : Route(
route = "Channel/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Event : Route(
route = "Event/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
}
@@ -1,7 +1,6 @@
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
@@ -27,6 +26,8 @@ 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.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -54,25 +55,33 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
LaunchedEffect(key1 = likeSetCard) {
scope.launch(Dispatchers.IO) {
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
val isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
}
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
val newBackgroundColor = if (isNew) {
newItemColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
}
}
}
Column(
modifier = Modifier
.background(backgroundColor)
.drawBehind {
drawRect(backgroundColor.value)
}
.combinedClickable(
onClick = {
scope.launch {
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -22,12 +21,14 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.collections.immutable.ImmutableSet
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) {
fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex: String? = null) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
Row() {
Column() {
Row(
modifier = Modifier.padding(
start = 20.dp,
@@ -45,10 +46,12 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: St
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
if (!showDivider) {
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
}
}
}
}
@@ -57,7 +60,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: St
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
reports: Set<Note>,
reports: ImmutableSet<Note>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
isQuote: Boolean = false,
@@ -93,7 +96,7 @@ fun HiddenNote(
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -23,7 +23,6 @@ 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
@@ -42,8 +41,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
@@ -62,11 +63,6 @@ fun ChatroomCompose(
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
val scope = rememberCoroutineScope()
val channelHex by remember(noteState) {
derivedStateOf {
noteState?.note?.channelHex()
@@ -77,125 +73,171 @@ fun ChatroomCompose(
BlankNote(Modifier)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
authorState?.user?.toBestDisplayName()
}
val chanHex = remember { channel.idHex }
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
}
}
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) {
scope.launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
hasNewMessages =
timestamp > notificationCache.cache.load("Channel/$chanHex")
}
}
}
ChannelName(
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
withStyle(
SpanStyle(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Normal
)
) {
append(" ${stringResource(id = R.string.public_chat)}")
}
},
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
},
channelLastTime = note.createdAt(),
channelLastContent = "$authorName: $description",
hasNewMessages = hasNewMessages,
onClick = { nav("Channel/$chanHex") }
)
ChannelRoomCompose(note, channel, nav)
}
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.verifiedRecipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
val userRoomHex = remember(noteState, accountViewModel) {
(note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
} ?: return
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
LoadUser(userRoomHex) { user ->
UserRoomCompose(note, user, accountViewModel, nav)
}
}
}
val noteEvent = note.event
@Composable
private fun ChannelRoomCompose(
note: Note,
channel: Channel,
nav: (String) -> Unit
) {
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
authorState?.user?.toBestDisplayName()
}
userToComposeOn.let { user ->
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val chanHex = remember { channel.idHex }
LaunchedEffect(key1 = notificationCache, key2 = note) {
scope.launch(Dispatchers.IO) {
noteEvent?.let {
hasNewMessages = it.createdAt() > notificationCache.cache.load(
"Room/${userToComposeOn.pubkeyHex}"
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
}
}
val noteEvent = note.event
val route = remember(note) {
"Channel/$chanHex"
}
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
WatchNotificationChanges(note, route) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
ChannelName(
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
withStyle(
SpanStyle(
fontWeight = FontWeight.Bold
)
) {
append(channelName)
}
}
}
ChannelName(
channelPicture = {
UserPicture(
userToComposeOn,
accountViewModel = accountViewModel,
size = 55.dp
)
withStyle(
SpanStyle(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Normal
)
) {
append(" ${stringResource(id = R.string.public_chat)}")
}
},
channelTitle = { UsernameDisplay(userToComposeOn, it) },
channelLastTime = note.createdAt(),
channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages,
onClick = { nav("Room/${user.pubkeyHex}") }
fontWeight = FontWeight.Bold,
modifier = modifier,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
},
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { "$authorName: $description" },
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
}
@Composable
private fun UserRoomCompose(
note: Note,
user: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
WatchNotificationChanges(note, route) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
ChannelName(
channelPicture = {
UserPicture(
user,
accountViewModel = accountViewModel,
size = 55.dp
)
},
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
}
@Composable
private fun WatchNotificationChanges(
note: Note,
route: String,
onNewStatus: (Boolean) -> Unit
) {
val cacheState by NotificationCache.live.observeAsState()
LaunchedEffect(key1 = note, cacheState) {
launch(Dispatchers.IO) {
note.event?.createdAt()?.let {
val lastTime = NotificationCache.load(route)
onNewStatus(it > lastTime)
}
}
}
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
var user by remember(baseUserHex) {
mutableStateOf<User?>(null)
}
LaunchedEffect(key1 = baseUserHex) {
if (user == null) {
launch(Dispatchers.IO) {
user = LocalCache.checkGetOrCreateUser(baseUserHex)
}
}
}
user?.let {
content(it)
}
}
@Composable
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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.MaterialTheme
@@ -28,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -53,10 +53,11 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@@ -65,13 +66,12 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -79,7 +79,7 @@ fun ChatroomMessageCompose(
baseNote: Note,
routeForLastRead: String?,
innerQuote: Boolean = false,
parentBackgroundColor: Color? = null,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
@@ -132,8 +132,11 @@ fun ChatroomMessageCompose(
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
val reports = remember {
account.getRelevantReports(noteForReports).toImmutableSet()
}
HiddenNote(
account.getRelevantReports(noteForReports),
reports,
accountViewModel,
Modifier,
innerQuote,
@@ -141,22 +144,30 @@ fun ChatroomMessageCompose(
onClick = { showHiddenNote = true }
)
} else {
val backgroundBubbleColor: Color
val alignment: Arrangement.Horizontal
val shape: Shape
val loggedInColors = MaterialTheme.colors.primary.copy(alpha = 0.32f)
val otherColors = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
val defaultBackground = MaterialTheme.colors.background
if (note.author == loggedIn) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
alignment = Arrangement.Start
shape = ChatBubbleShapeThem
val backgroundBubbleColor = remember {
if (note.author == loggedIn) {
mutableStateOf(loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
val alignment: Arrangement.Horizontal = remember {
if (note.author == loggedIn) {
Arrangement.End
} else {
Arrangement.Start
}
}
val shape: Shape = remember {
if (note.author == loggedIn) {
ChatBubbleShapeMe
} else {
ChatBubbleShapeThem
}
}
val scope = rememberCoroutineScope()
@@ -204,7 +215,7 @@ fun ChatroomMessageCompose(
}
) {
Surface(
color = backgroundBubbleColor,
color = backgroundBubbleColor.value,
shape = shape,
modifier = Modifier
.combinedClickable(
@@ -265,7 +276,6 @@ fun ChatroomMessageCompose(
else -> {
RenderRegularTextNote(
note,
loggedIn,
isAcceptableAndCanPreview.second,
backgroundBubbleColor,
accountViewModel,
@@ -344,7 +354,7 @@ fun ChatTimeAgo(time: Long) {
var timeStr by remember { mutableStateOf("") }
LaunchedEffect(key1 = time) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
timeStr = timeAgoShort(time, context = context)
}
}
@@ -359,13 +369,12 @@ fun ChatTimeAgo(time: Long) {
@Composable
private fun RenderRegularTextNote(
note: Note,
loggedIn: User,
canPreview: Boolean,
backgroundBubbleColor: Color,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember { note.event?.tags() ?: emptyList() }
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.padding(top = 5.dp) }
@@ -420,7 +429,7 @@ private fun RenderChangeChannelMetadataNote(
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
)
}
@@ -443,7 +452,7 @@ private fun RenderCreateChannelNote(note: Note) {
CreateTextWithEmoji(
text = text,
tags = note.author?.info?.latestMetadata?.tags
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
)
}
@@ -459,7 +468,7 @@ private fun DrawAuthorInfo(
val route = remember { "User/$pubkeyHex" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -470,17 +479,19 @@ private fun DrawAuthorInfo(
robot = pubkeyHex,
model = userProfilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
nav(route)
})
modifier = remember {
Modifier
.width(25.dp)
.height(25.dp)
.clip(shape = CircleShape)
.clickable(onClick = {
nav(route)
})
}
)
CreateClickableTextWithEmoji(
clickablePart = " $userDisplayName",
clickablePart = remember { " $userDisplayName" },
suffix = "",
tags = userTags,
fontWeight = FontWeight.Bold,
@@ -39,9 +39,6 @@ import kotlinx.coroutines.launch
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { messageSetCard.note }
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note }
val accountState by accountViewModel.accountLiveData.observeAsState()
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
@@ -49,71 +46,69 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier)
} else {
var isNew by remember { mutableStateOf(false) }
var isNew by remember { mutableStateOf(false) }
LaunchedEffect(key1 = messageSetCard.createdAt()) {
scope.launch(Dispatchers.IO) {
val newIsNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
LaunchedEffect(key1 = messageSetCard.createdAt()) {
launch(Dispatchers.IO) {
val newIsNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
if (newIsNew != isNew) {
isNew = newIsNew
}
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
val columnModifier = remember(isNew) {
Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
baseNote,
loggedIn
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
}
val columnModifier = remember(isNew) {
Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
baseNote,
loggedIn
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
}
Column(columnModifier) {
Row(Modifier.fillMaxWidth()) {
MessageIcon()
Column(columnModifier) {
Row(Modifier.fillMaxWidth()) {
MessageIcon()
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
val routeForLastRead = "Room/${(baseNote.event as? PrivateDmEvent)?.talkingWith(loggedIn.pubkeyHex)}"
NoteCompose(
baseNote = baseNote,
routeForLastRead = routeForLastRead,
isBoostedNote = true,
addMarginTop = false,
parentBackgroundColor = null,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
val routeForLastRead = remember(baseNote) {
"Room/${(baseNote.event as? PrivateDmEvent)?.talkingWith(loggedIn.pubkeyHex)}"
}
NoteCompose(
baseNote = baseNote,
routeForLastRead = routeForLastRead,
isBoostedNote = true,
addMarginTop = false,
parentBackgroundColor = null,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -23,6 +23,7 @@ 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.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -33,6 +34,7 @@ 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.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
@@ -47,6 +49,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,34 +67,34 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val baseNote = remember { multiSetCard.note }
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var isNew by remember { mutableStateOf(false) }
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
LaunchedEffect(key1 = multiSetCard.createdAt()) {
LaunchedEffect(key1 = multiSetCard) {
launch(Dispatchers.IO) {
val newIsNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
val isNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
if (newIsNew != isNew) {
isNew = newIsNew
val newBackgroundColor = if (isNew) {
newItemColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
}
}
}
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
val columnModifier = Modifier
.background(backgroundColor)
.drawBehind {
drawRect(backgroundColor.value)
}
.padding(
start = 12.dp,
end = 12.dp,
@@ -134,7 +137,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
modifier = remember { Modifier.padding(top = 5.dp) },
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
@@ -147,9 +150,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
@Composable
private fun RenderLikeGallery(
fun RenderLikeGallery(
likeEvents: ImmutableList<Note>,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -179,8 +182,8 @@ private fun RenderLikeGallery(
@Composable
fun RenderZapGallery(
zapEvents: ImmutableMap<Note, Note>,
backgroundColor: Color,
zapEvents: ImmutableMap<Note, Note?>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -209,9 +212,9 @@ fun RenderZapGallery(
}
@Composable
private fun RenderBoostGallery(
fun RenderBoostGallery(
boostEvents: ImmutableList<Note>,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -246,8 +249,8 @@ private fun RenderBoostGallery(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGalleryZaps(
authorNotes: ImmutableMap<Note, Note>,
backgroundColor: Color,
authorNotes: ImmutableMap<Note, Note?>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -264,7 +267,7 @@ fun AuthorGalleryZaps(
private fun AuthorPictureAndComment(
zapRequest: Note,
zapEvent: Note?,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -312,7 +315,7 @@ private fun AuthorPictureAndComment(
comment: String?,
amount: String?,
route: String,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -331,7 +334,7 @@ private fun AuthorPictureAndComment(
Box(modifier = remember { Modifier.size(35.dp) }, contentAlignment = Alignment.BottomCenter) {
FastNoteAuthorPicture(
author = author,
size = 35.dp,
size = remember { 35.dp },
accountViewModel = accountViewModel,
pictureModifier = authorPictureModifier
)
@@ -361,7 +364,7 @@ private fun AuthorPictureAndComment(
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { emptyList() },
tags = remember { ImmutableListOfLists() },
modifier = remember { Modifier.fillMaxWidth() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
@@ -375,14 +378,14 @@ private fun AuthorPictureAndComment(
@Composable
fun AuthorGallery(
authorNotes: ImmutableList<Note>,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
FlowRow() {
authorNotes.forEach { note ->
Box(Modifier.size(35.dp)) {
Box(remember { Modifier.size(35.dp) }) {
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
}
}
@@ -393,7 +396,7 @@ fun AuthorGallery(
@Composable
private fun NotePictureAndComment(
baseNote: Note,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
@@ -20,7 +20,6 @@ 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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -41,7 +40,7 @@ import java.util.Date
@Composable
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Boolean?> {
val nip05Verified = remember(user) {
val nip05Verified = remember(user.nip05) {
// starts with null if must verify or already filled in if verified in the last hour
val default = if ((user.nip05LastVerificationTime ?: 0) > (Date().time / 1000 - 60 * 60)) { // 1hour
user.nip05Verified
@@ -52,11 +51,9 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
mutableStateOf(default)
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = user) {
LaunchedEffect(key1 = user.nip05) {
if (nip05Verified.value == null) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
user.nip05?.ifBlank { null }?.let { nip05 ->
Nip05Verifier().verifyNip05(
nip05,
@@ -112,73 +109,89 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val parts = remember(userState) { userState?.user?.nip05()?.split("@") } ?: return
val isValidNIP05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()?.split("@")?.size == 2
}
}
val nip05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()
}
}
if (parts.size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (isValidNIP05) {
nip05?.let {
DisplayNIP05Line(it, baseUser, columnModifier)
}
}
}
Column(modifier = columnModifier) {
DisplayNIP05(parts[0], parts[1], nip05Verified)
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
val nip05Verified by nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
DisplayNIP05(nip05, nip05Verified)
}
}
}
@Composable
private fun DisplayNIP05(
user: String,
domain: String,
nip05: String,
nip05Verified: Boolean?
) {
val uri = LocalUriHandler.current
val (user, domain) = remember(nip05) {
nip05.split("@")
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (user != "_") {
Text(
text = AnnotatedString(user),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(domain),
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
if (user != "_") {
Text(
text = AnnotatedString(user),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Visible
overflow = TextOverflow.Ellipsis
)
}
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(domain),
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
@Composable
File diff suppressed because it is too large Load Diff
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private fun lightenColor(color: Color, amount: Float): Color {
@@ -109,6 +110,16 @@ private fun VerticalDivider(color: Color) =
.width(1.dp)
)
@Composable
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
var popupExpanded by remember { mutableStateOf(false) }
val showPopup = remember { { popupExpanded = true } }
content(showPopup)
NoteQuickActionMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
@Composable
fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
@@ -142,7 +153,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
Popup(onDismissRequest = onDismiss) {
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
@@ -154,25 +165,31 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
icon = Icons.Default.ContentCopy,
label = stringResource(R.string.quick_action_copy_text)
) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
scope.launch(Dispatchers.IO) {
clipboardManager.setText(
AnnotatedString(
accountViewModel.decrypt(note) ?: ""
)
)
)
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
scope.launch(Dispatchers.IO) {
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
}
if (!isOwnNote) {
@@ -180,8 +197,10 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
scope.launch(Dispatchers.IO) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
} else {
showBlockAlertDialog = true
}
@@ -198,21 +217,27 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.delete(note)
onDismiss()
}
} else {
showDeleteAlertDialog = true
}
}
} else if (isFollowingUser) {
NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) {
accountViewModel.unfollow(note.author!!)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.unfollow(note.author!!)
onDismiss()
}
}
} else {
NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) {
accountViewModel.follow(note.author!!)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.follow(note.author!!)
onDismiss()
}
}
}
@@ -221,9 +246,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast)
) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
}
VerticalDivider(primaryLight)
NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) {
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
@@ -27,9 +26,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@@ -39,7 +42,7 @@ import kotlin.math.roundToInt
fun PollNote(
baseNote: Note,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -64,7 +67,7 @@ fun PollNote(
baseNote: Note,
pollViewModel: PollNoteViewModel,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -106,10 +109,12 @@ private fun OptionNote(
baseNote: Note,
accountViewModel: AccountViewModel,
canPreview: Boolean,
backgroundColor: Color,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit
) {
val tags = remember { baseNote.event?.tags() ?: emptyList() }
val tags = remember(baseNote) {
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -163,8 +168,8 @@ private fun RenderOptionAfterVote(
totalRatio: Float,
color: Color,
canPreview: Boolean,
tags: List<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
@@ -175,11 +180,11 @@ private fun RenderOptionAfterVote(
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
QuoteBorder
)
) {
LinearProgressIndicator(
@@ -230,19 +235,19 @@ private fun RenderOptionAfterVote(
private fun RenderOptionBeforeVote(
description: String,
canPreview: Boolean,
tags: List<List<String>>,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.clip(shape = QuoteBorder)
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
QuoteBorder
)
) {
TranslatableRichTextViewer(
@@ -486,7 +491,7 @@ fun FilteredZapAmountChoicePopup(
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
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
@@ -14,7 +16,6 @@ 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.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
@@ -25,11 +26,14 @@ import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
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.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -62,22 +66,194 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ReactionsRow(baseNote: Note, showReactionDetail: Boolean, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember {
var wantsToSeeReactions = remember {
mutableStateOf<Boolean>(false)
}
Spacer(modifier = Modifier.height(7.dp))
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp)) {
if (showReactionDetail) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.width(65.dp)
.padding(start = 31.dp),
horizontalArrangement = Arrangement.Start
) {
ExpandButton(baseNote, wantsToSeeReactions)
}
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ReplyReactionWithDialog(accountViewModel, nav, baseNote, grayTint)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
BoostWithDialog(accountViewModel, nav, baseNote, grayTint)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ZapReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ViewCountReaction(baseNote.idHex, grayTint)
}
}
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
}
Spacer(modifier = Modifier.height(7.dp))
}
@Composable
private fun ExpandButton(baseNote: Note, wantsToSeeReactions: MutableState<Boolean>) {
val zapsState by baseNote.live().zaps.observeAsState()
val boostsState by baseNote.live().boosts.observeAsState()
val reactionsState by baseNote.live().reactions.observeAsState()
val hasReactions by remember(zapsState, boostsState, reactionsState) {
derivedStateOf {
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
}
}
if (hasReactions) {
IconButton(
onClick = {
wantsToSeeReactions.value = !wantsToSeeReactions.value
},
modifier = Modifier.size(20.dp)
) {
if (wantsToSeeReactions.value) {
Icon(
imageVector = Icons.Default.ExpandLess,
null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
)
} else {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
)
}
}
}
}
@Composable
private fun ReactionDetailGallery(
baseNote: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val boostsState by baseNote.live().boosts.observeAsState()
val reactionsState by baseNote.live().reactions.observeAsState()
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val hasReactions by remember(zapsState, boostsState, reactionsState) {
derivedStateOf {
baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty()
}
}
if (hasReactions) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(start = 10.dp, top = 5.dp)) {
Column() {
val zapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.toImmutableMap() } }
val boostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.toImmutableList() } }
val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableList() } }
val hasZapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.isNotEmpty() } }
val hasBoostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.isNotEmpty() } }
val hasLikeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.isNotEmpty() } }
if (hasZapEvents) {
RenderZapGallery(
zapEvents,
backgroundColor,
nav,
accountViewModel
)
}
if (hasBoostEvents) {
RenderBoostGallery(
boostEvents,
backgroundColor,
nav,
accountViewModel
)
}
if (hasLikeEvents) {
RenderLikeGallery(
likeEvents,
backgroundColor,
nav,
accountViewModel
)
}
}
}
}
}
@Composable
private fun BoostWithDialog(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
baseNote: Note,
grayTint: Color
) {
var wantsToQuote by remember {
mutableStateOf<Note?>(null)
}
var wantsToQuote by remember {
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
}
BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote
}
}
@Composable
private fun ReplyReactionWithDialog(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
baseNote: Note,
grayTint: Color
) {
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -85,32 +261,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (Strin
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, grayTint, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, grayTint, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex, grayTint)
}
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
}
@@ -120,7 +272,7 @@ fun ReplyReaction(
grayTint: Color,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 20.dp,
iconSize: Dp = 18.dp,
onPress: () -> Unit
) {
val context = LocalContext.current
@@ -201,7 +353,9 @@ fun BoostReaction(
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
accountViewModel.deleteBoostsTo(baseNote)
scope.launch(Dispatchers.IO) {
accountViewModel.deleteBoostsTo(baseNote)
}
} else {
wantsToBoost = true
}
@@ -293,10 +447,12 @@ fun LikeReaction(
modifier = iconButtonModifier,
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
scope.launch(Dispatchers.IO) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
}
}
} else {
scope.launch {
@@ -386,9 +542,6 @@ fun ZapReaction(
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
@@ -407,53 +560,18 @@ fun ZapReaction(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.no_zap_amount_setup_long_press_to_change),
Toast.LENGTH_SHORT
)
.show()
zapClick(
baseNote,
accountViewModel,
scope,
context,
onZappingProgress = { progress: Float ->
zappingProgress = progress
},
onMultipleChoices = {
wantsToZap = true
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
null,
"",
context,
onError = {
scope.launch {
zappingProgress = 0f
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
},
zapType = account.defaultZapType
)
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
}
)
},
onLongClick = {
wantsToChangeZapAmount = true
@@ -494,7 +612,7 @@ fun ZapReaction(
}
if (wantsToSetCustomZap) {
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote)
}
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
@@ -507,7 +625,7 @@ fun ZapReaction(
CircularProgressIndicator(
progress = animatedProgress,
modifier = Modifier.size(animationSize),
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp
)
} else {
@@ -523,6 +641,63 @@ fun ZapReaction(
ZapAmountText(baseNote, grayTint, accountViewModel)
}
private fun zapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
scope: CoroutineScope,
context: Context,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit
) {
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.no_zap_amount_setup_long_press_to_change),
Toast.LENGTH_SHORT
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps),
Toast.LENGTH_SHORT
)
.show()
}
} else if (accountViewModel.account.zapAmountChoices.size == 1) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
accountViewModel.account.zapAmountChoices.first() * 1000,
null,
"",
context,
onError = {
scope.launch {
onZappingProgress(0f)
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
},
onProgress = {
scope.launch(Dispatchers.Main) {
onZappingProgress(it)
}
},
zapType = accountViewModel.account.defaultZapType
)
}
} else if (accountViewModel.account.zapAmountChoices.size > 1) {
onMultipleChoices()
}
}
@Composable
private fun ZapIcon(
baseNote: Note,
@@ -593,7 +768,7 @@ private fun ZapAmountText(
}
@Composable
public fun ViewCountReaction(
fun ViewCountReaction(
idHex: String,
grayTint: Color,
iconSize: Dp = 20.dp,
@@ -653,14 +828,17 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
offset = IntOffset(0, -50),
onDismissRequest = { onDismiss() }
) {
FlowRow() {
FlowRow {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.boost(baseNote)
onDismiss()
scope.launch(Dispatchers.IO) {
accountViewModel.boost(baseNote)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -672,7 +850,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onQuote,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -725,7 +903,7 @@ fun ZapAmountChoicePopup(
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
@@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -94,7 +94,7 @@ fun AddRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -110,7 +110,7 @@ fun RemoveRelayButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
@@ -18,24 +18,29 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ReplyInformation(
replyTo: List<Note>?,
mentions: List<String>,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
var sortedMentions by remember { mutableStateOf<ImmutableList<User>?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
?.toSet()?.sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) }
.toSet()
.sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) }
.toImmutableList()
}
}
@@ -49,8 +54,8 @@ fun ReplyInformation(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ReplyInformation(
replyTo: List<Note>?,
sortedMentions: List<User>?,
replyTo: ImmutableList<Note>?,
sortedMentions: ImmutableList<User>?,
prefix: String = "",
onUserTagClick: (User) -> Unit
) {
@@ -119,13 +124,13 @@ private fun ReplyInformation(
@Composable
fun ReplyInformationChannel(
replyTo: List<Note>?,
mentions: List<String>,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
channelHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
var sortedMentions by remember { mutableStateOf<ImmutableList<User>?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
@@ -133,6 +138,7 @@ fun ReplyInformationChannel(
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { accountViewModel.account.isFollowing(it) }
.toImmutableList()
}
}
@@ -154,7 +160,7 @@ fun ReplyInformationChannel(
}
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, nav: (String) -> Unit) {
fun ReplyInformationChannel(replyTo: ImmutableList<Note>?, mentions: ImmutableList<User>?, channel: Channel, nav: (String) -> Unit) {
ReplyInformationChannel(
replyTo,
mentions,
@@ -171,8 +177,8 @@ fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformationChannel(
replyTo: List<Note>?,
mentions: List<User>?,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<User>?,
baseChannel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
@@ -181,6 +187,10 @@ fun ReplyInformationChannel(
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val channelName = remember(channelState) {
AnnotatedString("${channel.info.name} ")
}
FlowRow() {
Text(
stringResource(R.string.in_channel),
@@ -189,7 +199,7 @@ fun ReplyInformationChannel(
)
ClickableText(
AnnotatedString("${channel.info.name} "),
text = channelName,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
@@ -231,17 +241,14 @@ private fun ReplyInfoMention(
onUserTagClick: (User) -> Unit
) {
val innerUserState by user.live().metadata.observeAsState()
val innerUser = remember(innerUserState) {
innerUserState?.user
} ?: return
CreateClickableTextWithEmoji(
clickablePart = "$prefix${innerUser.toBestDisplayName()}",
tags = innerUser.info?.latestMetadata?.tags,
clickablePart = remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" },
tags = remember(innerUserState) { innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(alpha = 0.52f),
fontSize = 13.sp
),
onClick = { onUserTagClick(innerUser) }
onClick = { onUserTagClick(user) }
)
}
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
@@ -78,6 +77,8 @@ import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
@@ -212,8 +213,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
LaunchedEffect(accountViewModel) {
postViewModel.load()
@@ -279,7 +280,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
@@ -334,7 +335,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -83,28 +84,48 @@ fun UserReactionsRow(
)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserReplyModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserBoostModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserReactionModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val zaps by model.zaps.collectAsState()
UserZapReaction(zaps[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserZapModel(model)
}
}
}
@Composable
private fun UserZapModel(model: UserReactionsViewModel) {
val zaps by model.zaps.collectAsState()
UserZapReaction(showAmountAxis(zaps[model.today()]))
}
@Composable
private fun UserReactionModel(model: UserReactionsViewModel) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
}
@Composable
private fun UserBoostModel(model: UserReactionsViewModel) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
}
@Composable
private fun UserReplyModel(model: UserReactionsViewModel) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
}
@Stable
class UserReactionsViewModel(val account: Account) : ViewModel() {
val user: User = account.userProfile()
@@ -275,6 +296,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
invalidateInsertData(newNotes)
}
}
@@ -290,6 +313,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
@@ -368,10 +392,8 @@ fun UserLikeReaction(
@Composable
fun UserZapReaction(
amount: BigDecimal?
amount: String
) {
val showAmounts = remember(amount) { showAmountAxis(amount) }
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -382,7 +404,7 @@ fun UserZapReaction(
Spacer(modifier = Modifier.width(8.dp))
Text(
showAmounts,
amount,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -11,6 +11,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@Composable
@@ -29,7 +31,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@@ -39,7 +41,7 @@ private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
tags: List<List<String>>?,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
@@ -49,7 +51,7 @@ private fun UserNameDisplay(
fontWeight = FontWeight.Bold
)
CreateTextWithEmoji(
text = "@$bestUserName",
text = remember { "@$bestUserName" },
tags = tags,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
@@ -67,7 +69,7 @@ private fun UserNameDisplay(
)
} else if (bestUserName != null) {
CreateTextWithEmoji(
text = "@$bestUserName",
text = remember { "@$bestUserName" },
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -26,6 +25,8 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -56,13 +57,13 @@ class ZapOptionstViewModel : ViewModel() {
}
@Composable
fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: ZapOptionstViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account)
LaunchedEffect(accountViewModel) {
postViewModel.load(accountViewModel.account)
}
var zappingProgress by remember { mutableStateOf(0f) }
@@ -74,9 +75,9 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
Dialog(
onDismissRequest = { onClose() },
@@ -158,7 +159,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
TextSpinner(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
@@ -216,7 +217,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Button(
onClick = { onPost() },
shape = RoundedCornerShape(20.dp),
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
@@ -38,37 +39,44 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNoteRequest by baseNote.first.live().metadata.observeAsState()
val noteZapRequest = remember(baseNoteRequest) { baseNoteRequest?.note } ?: return
fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNoteRequest by baseReqResponse.zapRequest.live().metadata.observeAsState()
var baseAuthor by remember {
mutableStateOf(noteZapRequest.author)
mutableStateOf<User?>(null)
}
LaunchedEffect(baseNoteRequest) {
launch(Dispatchers.Default) {
(baseNoteRequest?.note?.event as? LnZapRequestEvent)?.let {
baseNoteRequest?.note?.let {
val decryptedContent = accountViewModel.decryptZap(it)
if (decryptedContent != null) {
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
} else {
baseAuthor = it.author
}
}
}
}
}
if (baseAuthor == null) {
BlankNote()
} else {
val route = remember(baseAuthor) {
"User/${baseAuthor?.pubkeyHex}"
}
Column(
modifier =
Modifier.clickable(
onClick = { nav("User/${baseAuthor?.pubkeyHex}") }
onClick = { nav(route) }
),
verticalArrangement = Arrangement.Center
) {
LaunchedEffect(Unit) {
launch(Dispatchers.Default) {
(noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(noteZapRequest)
if (decryptedContent != null) {
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
}
}
}
}
baseAuthor?.let {
RenderZapNote(it, baseNote.second, nav, accountViewModel)
RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel)
}
Divider(
@@ -87,30 +95,37 @@ private fun RenderZapNote(
accountViewModel: AccountViewModel
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
modifier = remember {
Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
},
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
modifier = remember {
Modifier
.padding(start = 10.dp)
.weight(1f)
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseAuthor)
}
AboutDisplay(baseAuthor)
Row(verticalAlignment = Alignment.CenterVertically) {
AboutDisplay(baseAuthor)
}
}
Column(
modifier = Modifier.padding(start = 10.dp),
modifier = remember {
Modifier.padding(start = 10.dp)
},
verticalArrangement = Arrangement.Center
) {
ZapAmount(zapNote)
@@ -125,13 +140,15 @@ private fun RenderZapNote(
@Composable
private fun ZapAmount(zapEventNote: Note) {
val noteState by zapEventNote.live().metadata.observeAsState()
val noteZap = remember(noteState) { noteState?.note } ?: return
var zapAmount by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteZap) {
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
zapAmount = showAmountAxis((noteZap.event as? LnZapEvent)?.amount)
val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount)
if (zapAmount != newZapAmount) {
zapAmount = newZapAmount
}
}
}
@@ -150,8 +167,7 @@ fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val coroutineScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
derivedStateOf {
@@ -159,21 +175,42 @@ fun UserActionOptions(
}
}
val userState by accountViewModel.account.userProfile().live().follows.observeAsState()
val isFollowing by remember(userState) {
derivedStateOf {
userState?.user?.isFollowingCached(baseAuthor) ?: false
if (isHidden) {
ShowUserButton {
scope.launch(Dispatchers.IO) {
accountViewModel.show(baseAuthor)
}
}
} else {
ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel)
}
}
@Composable
fun ShowFollowingOrUnfollowingButton(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
var isFollowing by remember { mutableStateOf(false) }
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(key1 = accountFollowsState) {
launch(Dispatchers.Default) {
val newShowFollowingMark =
accountFollowsState?.user?.isFollowing(baseAuthor) == true
if (newShowFollowingMark != isFollowing) {
isFollowing = newShowFollowingMark
}
}
}
if (isHidden) {
ShowUserButton {
accountViewModel.show(baseAuthor)
}
} else if (isFollowing) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
if (isFollowing) {
UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -22,6 +21,8 @@ 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.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -34,32 +35,35 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var isNew by remember { mutableStateOf<Boolean>(false) }
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
LaunchedEffect(key1 = zapSetCard.createdAt()) {
launch(Dispatchers.IO) {
val newIsNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
val isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
val newBackgroundColor = if (isNew) {
newItemColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
if (backgroundColor.value != newBackgroundColor) {
backgroundColor.value = newBackgroundColor
}
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.drawBehind {
drawRect(backgroundColor.value)
}
.clickable {
nav("User/${zapSetCard.user.pubkeyHex}")
}
@@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -87,7 +88,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
CreateTextWithEmoji(
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
tags = user.info?.latestMetadata?.tags,
tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -22,6 +23,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes
import java.util.regex.Pattern
@Stable
class AccountStateViewModel(val context: Context) : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
@@ -19,6 +19,7 @@ import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -26,6 +27,7 @@ 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.unit.dp
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
@@ -33,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -45,8 +48,15 @@ fun RefresheableCardView(
enablePullRefresh: Boolean = true
) {
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
val pullRefreshState = rememberPullRefreshState(
refreshing,
onRefresh =
{
refreshing = true
viewModel.invalidateData()
refreshing = false
}
)
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
@@ -92,9 +102,11 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
}
@@ -110,11 +122,10 @@ fun RenderCardFeed(
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
modifier = Modifier.fillMaxSize(),
modifier = remember { Modifier.fillMaxSize() },
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
@@ -151,7 +162,9 @@ private fun FeedLoaded(
routeForLastRead: String
) {
val defaultModifier = remember {
Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp)
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 100.dp)
}
LazyColumn(
@@ -166,45 +179,88 @@ private fun FeedLoaded(
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
Row(defaultModifier) {
when (item) {
is NoteCard -> NoteCompose(
item.note,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
RenderCardItem(item, accountViewModel, nav, routeForLastRead)
}
}
}
}
@Composable
private fun RenderCardItem(
item: Card,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
) {
when (item) {
is NoteCard -> NoteCompose(
item,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun NoteCompose(
baseNote: NoteCard,
routeForLastRead: String? = null,
modifier: Modifier = remember { Modifier },
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
addMarginTop: Boolean = true,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val note = remember(baseNote) {
baseNote.note
}
NoteCompose(
baseNote = note,
routeForLastRead = routeForLastRead,
modifier = modifier,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
addMarginTop = addMarginTop,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}

Some files were not shown because too many files have changed in this diff Show More