mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 22:54:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7007796497 | ||
|
|
9f030ffbf8 | ||
|
|
9ca172e0fc | ||
|
|
360758a2f3 | ||
|
|
ffe6f4b55c | ||
|
|
267b5e474c | ||
|
|
bf6bc5ba68 | ||
|
|
1efc4c91e2 | ||
|
|
a898196571 | ||
|
|
c158c7fc3d | ||
|
|
e3d83fe39a | ||
|
|
5327e1ddb2 | ||
|
|
a2ad13c54a | ||
|
|
816a22ec69 | ||
|
|
8a99dc2db4 | ||
|
|
f9cfe1479b | ||
|
|
5d8aa7bb58 | ||
|
|
b3e2b78415 | ||
|
|
d0094cc781 | ||
|
|
cb77298f52 | ||
|
|
24532c8f20 | ||
|
|
b641684377 | ||
|
|
9589f68028 | ||
|
|
8b852d32e4 | ||
|
|
5b864fa1d9 | ||
|
|
8e91987c31 | ||
|
|
1c3b34a9c7 | ||
|
|
5a44f8d663 | ||
|
|
2ab7e6b24d | ||
|
|
e9f8fd6cec | ||
|
|
8d7060afe6 | ||
|
|
784f983746 | ||
|
|
ecc4141f50 | ||
|
|
b964c6a7fa | ||
|
|
5d37e337bc | ||
|
|
d9815b669b | ||
|
|
6906993cc3 | ||
|
|
705309cb8a | ||
|
|
89bdc9dd58 | ||
|
|
ac0103a53e | ||
|
|
b2c31d3b46 | ||
|
|
931bd58c51 | ||
|
|
6a7d6a843d | ||
|
|
eaae672a30 | ||
|
|
ccfbfeffe2 | ||
|
|
eb62da8d26 | ||
|
|
d334c6b2e1 | ||
|
|
90f0e4aad1 | ||
|
|
fabf52ffd7 | ||
|
|
c499c4baec | ||
|
|
4bd19c3e3d | ||
|
|
6a47f5aa3b | ||
|
|
03d1754498 | ||
|
|
e9374370b9 | ||
|
|
88a5e309bd | ||
|
|
f7a292598e | ||
|
|
cff8684347 | ||
|
|
b9654d164f | ||
|
|
c10db10430 | ||
|
|
50f3f7f176 | ||
|
|
49c3334509 | ||
|
|
e75f70985a | ||
|
|
e956f51f19 | ||
|
|
165678f118 | ||
|
|
d32d2da280 | ||
|
|
d6a6a52821 | ||
|
|
dfb697975f | ||
|
|
c0c0fd9e6d | ||
|
|
5bdcdc6f4a | ||
|
|
102ab63673 | ||
|
|
b34c1f98d5 | ||
|
|
74b05e3d6a | ||
|
|
972bce426a | ||
|
|
3de57d6ae2 | ||
|
|
5695a82c20 | ||
|
|
e171120836 |
@@ -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
@@ -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'
|
||||
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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>>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-30
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
+33
-20
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -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
Reference in New Issue
Block a user