mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605d83e530 | ||
|
|
5f710b7304 | ||
|
|
1c6593fb17 | ||
|
|
33fe34d3b3 | ||
|
|
e257fec92c | ||
|
|
6a70a6c72f | ||
|
|
270d6b411a | ||
|
|
803b1e861e | ||
|
|
7300dc3d78 | ||
|
|
405d64402b | ||
|
|
fe1bff1074 | ||
|
|
2952ca1978 | ||
|
|
32dcd7747b | ||
|
|
eca5a1de15 | ||
|
|
886442ac52 | ||
|
|
8521611008 | ||
|
|
df647e4047 | ||
|
|
af26026d25 | ||
|
|
43be55f3d6 | ||
|
|
9b3cb92cb3 | ||
|
|
f2b0ce5fd0 | ||
|
|
787ca98daf | ||
|
|
71517dc454 | ||
|
|
949301d58f | ||
|
|
cf8ac4faae | ||
|
|
ad3aa124fc | ||
|
|
2326aa9f7c | ||
|
|
acfd687d74 | ||
|
|
9b9183395d | ||
|
|
45b4902b2b | ||
|
|
6056d60d32 | ||
|
|
61af248c09 | ||
|
|
5a580db1b8 | ||
|
|
a72ae49e93 | ||
|
|
40a01219cc | ||
|
|
c652390a9c | ||
|
|
0916a97526 | ||
|
|
d76ef0a1ca | ||
|
|
3a70ce1587 | ||
|
|
9f96e84378 | ||
|
|
985771beba | ||
|
|
3967633f77 | ||
|
|
ee5699c2de | ||
|
|
b8f4d9f0bb | ||
|
|
8b1de47517 | ||
|
|
c6ff514ba2 | ||
|
|
50f2d18b61 | ||
|
|
fa7ccdfc20 | ||
|
|
fe02519737 | ||
|
|
f2e9fbebea | ||
|
|
52af109b4e | ||
|
|
833e6bc3e1 | ||
|
|
9bed6fdb9a | ||
|
|
a4a867ebcd | ||
|
|
4da2f18957 | ||
|
|
4141a52d51 | ||
|
|
4dbc71d577 | ||
|
|
3b5d792562 | ||
|
|
dd7f3225c6 | ||
|
|
3477e7b8ba | ||
|
|
a3782bb459 | ||
|
|
d4003eee04 | ||
|
|
c9bbb66284 | ||
|
|
1498fa9868 | ||
|
|
26b493aa1d | ||
|
|
f9d652f849 | ||
|
|
30b0ee444f | ||
|
|
bf8341ef58 | ||
|
|
f9f1b0d98e | ||
|
|
743a4c9d87 | ||
|
|
275baa7497 | ||
|
|
ac1fb879c3 | ||
|
|
270ad56450 | ||
|
|
d155241efc | ||
|
|
d61927cbee | ||
|
|
996b68f9c6 | ||
|
|
f6913663d1 | ||
|
|
58600e13a8 | ||
|
|
54e15ad3cd | ||
|
|
777c652bf5 | ||
|
|
7fb1473e21 | ||
|
|
95db1ec84f | ||
|
|
7438973a20 | ||
|
|
fe31855860 | ||
|
|
d7bce4414d | ||
|
|
b4a81c137c | ||
|
|
a6390f58d9 | ||
|
|
ca3f4c3ec6 | ||
|
|
53bfd40a31 | ||
|
|
798be25c43 | ||
|
|
ebe1bcedd0 | ||
|
|
4731575b87 | ||
|
|
383e761372 | ||
|
|
54a95e6a4e | ||
|
|
3105ade755 | ||
|
|
f651034303 | ||
|
|
7554dc010c | ||
|
|
70a962062b | ||
|
|
5cc7f3bf2b | ||
|
|
b040e0ca13 | ||
|
|
775094356c | ||
|
|
0771b179b5 | ||
|
|
8af307e8bb | ||
|
|
e6ec3f1ce4 | ||
|
|
b4395e7585 | ||
|
|
f8acb26763 | ||
|
|
aa931a4cc5 | ||
|
|
e1cf188fbe | ||
|
|
9e57a706ae | ||
|
|
8c73e981b5 | ||
|
|
87f5007fd3 | ||
|
|
5c42bb0282 | ||
|
|
415252b770 | ||
|
|
4f43d7cde5 | ||
|
|
c819a23f83 | ||
|
|
4a00a9a80d | ||
|
|
1f5f6b251c | ||
|
|
c925b82e86 | ||
|
|
96a5d1a658 | ||
|
|
36154422f1 | ||
|
|
5b2a878f5d | ||
|
|
976d96ef35 | ||
|
|
94b31fe5f9 | ||
|
|
cd2a59ae43 | ||
|
|
e8f5ef3aa3 | ||
|
|
887fc33073 | ||
|
|
c24ba18207 | ||
|
|
5137009f5f | ||
|
|
ba6bd6a2d2 | ||
|
|
bfc16d0878 | ||
|
|
94c41e0c6d | ||
|
|
33454cc2f8 | ||
|
|
d969a483a9 | ||
|
|
a51104fd82 | ||
|
|
a536388123 | ||
|
|
c7cb4b7015 | ||
|
|
fbb1b59e34 | ||
|
|
7d7f047f55 | ||
|
|
e9e0b0f57f | ||
|
|
baf0372537 | ||
|
|
f62716a226 | ||
|
|
9b1c47f4a5 | ||
|
|
a4dd3aa488 | ||
|
|
9dbe7379cd | ||
|
|
ad353d2d1a | ||
|
|
32b2b1ace8 | ||
|
|
13710dde06 | ||
|
|
915ddd0bad | ||
|
|
52ef356c06 | ||
|
|
279894f9d7 | ||
|
|
ffa03e1d0d | ||
|
|
719f5c2ac6 | ||
|
|
baa3a21dd2 | ||
|
|
8cb84c2395 | ||
|
|
ee52e725d9 | ||
|
|
f3e0c31087 | ||
|
|
92ccdfc55f | ||
|
|
0528e59572 | ||
|
|
0ab3219115 | ||
|
|
724cfddb69 | ||
|
|
7fcc28a320 | ||
|
|
94051f178b | ||
|
|
8e4f339aa1 | ||
|
|
425aa69691 | ||
|
|
1c65334b91 | ||
|
|
726d04f0f4 | ||
|
|
9d1ab5a9b8 | ||
|
|
09bad79870 |
20
README.md
20
README.md
@@ -4,6 +4,11 @@
|
||||
|
||||
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
|
||||
|
||||
[<img src="./docs/design/obtainium.png"
|
||||
alt="Get it on Obtaininum"
|
||||
height="80">](https://github.com/ImranR98/Obtainium)
|
||||
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
|
||||
height="80">](https://github.com/vitorpamplona/amethyst/releases)
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
|
||||
@@ -11,15 +16,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
alt="Get it on Google Play"
|
||||
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
|
||||
|
||||
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (likes NIP-25, boost, reply)
|
||||
- [x] Replies and mentions (NIP-10)
|
||||
- [x] Reactions (NIP-25)
|
||||
- [x] Reposts (NIP-18)
|
||||
- [x] Image/Video/Url/LnInvoice Previews
|
||||
- [x] View Threads
|
||||
- [x] Private Messages (NIP-04)
|
||||
@@ -56,6 +61,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Zap Forwarding (NIP-57)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
@@ -67,6 +73,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Generic Reposts (kind:16)
|
||||
- [x] Live Activities & Live Chats (NIP-102)
|
||||
- [x] Relay Pages (NIP-11)
|
||||
- [x] HTTP Auth (NIP-98)
|
||||
- [x] Zapraiser (NIP-TBD)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
@@ -186,6 +194,12 @@ Information that you share is publicly visible to anyone reading from relays tha
|
||||
## Information Permanence ##
|
||||
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
|
||||
|
||||
# Screenshots
|
||||
|
||||
| Home Feed | Messages | Live Streams | Notifications |
|
||||
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
|
||||
|  |  |  |  |
|
||||
|
||||
# Contributing
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
BIN
amethyst.png
BIN
amethyst.png
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 16 KiB |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 220
|
||||
versionName "0.61.4"
|
||||
versionCode 230
|
||||
versionName "0.63.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -181,7 +181,7 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Charts
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 22 KiB |
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
@@ -66,6 +67,7 @@ object ServiceManager {
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
|
||||
@@ -322,6 +322,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createHTTPAuthorization(url: String, method: String, body: String? = null): HTTPAuthorizationEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return HTTPAuthorizationEvent.create(url, method, body, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1128,7 +1134,7 @@ class Account(
|
||||
user.countReportAuthorsBy(followingKeySet()) < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
private fun isAcceptableDirect(note: Note): Boolean {
|
||||
if (!warnAboutPostsWithReports) {
|
||||
return note.reportsBy(userProfile()).isEmpty()
|
||||
}
|
||||
@@ -1222,8 +1228,7 @@ class Account(
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
it.cache.spamMessages.snapshot().values.forEach {
|
||||
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
|
||||
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
|
||||
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
|
||||
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
|
||||
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
|
||||
live.invalidateData()
|
||||
}
|
||||
@@ -1234,6 +1239,7 @@ class Account(
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "Account")
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.OptOutFromFilters
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
@@ -17,7 +18,6 @@ class AntiSpamFilter {
|
||||
val recentMessages = LruCache<Int, String>(1000)
|
||||
val spamMessages = LruCache<Int, Spammer>(1000)
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -44,15 +44,9 @@ class AntiSpamFilter {
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
if (spamMessages.get(hash) == null) {
|
||||
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
|
||||
liveSpam.invalidateData()
|
||||
} else {
|
||||
val spammer = spamMessages.get(hash)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
logOffender(hash, event)
|
||||
|
||||
liveSpam.invalidateData()
|
||||
}
|
||||
liveSpam.invalidateData()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -62,9 +56,20 @@ class AntiSpamFilter {
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun logOffender(hashCode: Int, event: Event) {
|
||||
if (spamMessages.get(hashCode) == null) {
|
||||
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
|
||||
} else {
|
||||
val spammer = spamMessages.get(hashCode)
|
||||
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
|
||||
@@ -33,7 +33,7 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
if (info.picture.isNullOrBlank()) return super.profilePicture()
|
||||
return info.picture ?: super.profilePicture()
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
return info?.image()?.ifBlank { null } ?: super.profilePicture()
|
||||
return info?.image()?.ifBlank { null }
|
||||
}
|
||||
|
||||
override fun anyNameStartsWith(prefix: String): Boolean {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
@@ -17,10 +18,13 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
"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))
|
||||
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, 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
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -12,7 +13,6 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import nostr.postr.toNpub
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -41,19 +41,18 @@ object LocalCache {
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
// checkNotInMainThread()
|
||||
|
||||
return users[key] ?: run {
|
||||
val answer = User(key)
|
||||
users.put(key, answer)
|
||||
answer
|
||||
val newObject = User(key)
|
||||
users.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserIfExists(key: String): User? {
|
||||
return users[key]
|
||||
if (key.isEmpty()) return null
|
||||
return users.get(key)
|
||||
}
|
||||
|
||||
fun getNoteIfExists(key: String): Note? {
|
||||
@@ -83,14 +82,12 @@ object LocalCache {
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
answer
|
||||
return notes.get(idHex) ?: run {
|
||||
val newObject = Note(idHex)
|
||||
notes.putIfAbsent(idHex, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,23 +109,18 @@ object LocalCache {
|
||||
}
|
||||
|
||||
private fun isValidHexNpub(key: String): Boolean {
|
||||
return try {
|
||||
Hex.decode(key).toNpub()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
false
|
||||
}
|
||||
if (key.isBlank()) return false
|
||||
if (key.contains(":")) return false
|
||||
|
||||
return HexValidator.isHex(key)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
|
||||
checkNotInMainThread()
|
||||
|
||||
return channels[key] ?: run {
|
||||
val answer = channelFactory(key)
|
||||
channels.put(key, answer)
|
||||
answer
|
||||
val newObject = channelFactory(key)
|
||||
channels.putIfAbsent(key, newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,16 +138,14 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
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)
|
||||
addressables.put(key.toTag(), answer)
|
||||
answer
|
||||
val newObject = AddressableNote(key)
|
||||
addressables.putIfAbsent(key.toTag(), newObject) ?: newObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +232,7 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
@@ -314,7 +304,7 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Prepares user's profile view.
|
||||
author.addNote(note)
|
||||
@@ -544,8 +534,7 @@ object LocalCache {
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -1280,12 +1269,13 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LocalCacheLiveData {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
|
||||
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
|
||||
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
|
||||
@@ -30,7 +30,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
override fun createdAt(): Long? {
|
||||
if (event == null) return null
|
||||
|
||||
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
|
||||
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
|
||||
|
||||
return minOf(publishedAt, lastCreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -70,11 +77,20 @@ open class Note(val idHex: String) {
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
return if (event is ChannelMessageEvent ||
|
||||
event is ChannelMetadataEvent ||
|
||||
event is ChannelCreateEvent ||
|
||||
event is LiveActivitiesChatMessageEvent ||
|
||||
event is LiveActivitiesEvent
|
||||
) {
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
|
||||
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -490,9 +506,9 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate() {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ data class RelaySetupInfo(
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
val feedTypes: Set<FeedType>,
|
||||
val paidRelay: Boolean = false
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
|
||||
@Stable
|
||||
object UrlCachedPreviewer {
|
||||
var cache = LruCache<String, UrlPreviewState>(100)
|
||||
private set
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -144,8 +146,10 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun updateAcceptedBadges(note: AddressableNote) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
if (acceptedBadges?.idHex != note.idHex) {
|
||||
acceptedBadges = note
|
||||
liveSet?.badges?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
@@ -185,34 +189,34 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
privateChatroom.removeMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -235,6 +239,7 @@ class User(val pubkeyHex: String) {
|
||||
info = newUserInfo
|
||||
info?.latestMetadata = latestMetadata
|
||||
info?.updatedMetadataAt = latestMetadata.createdAt
|
||||
info?.tags = latestMetadata.tags.toImmutableListOfLists()
|
||||
|
||||
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
|
||||
try {
|
||||
@@ -371,7 +376,27 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
data class Chatroom(var roomMessages: Set<Note>)
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeMessageSync(msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class UserMetadata {
|
||||
@@ -399,6 +424,7 @@ class UserMetadata {
|
||||
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
var tags: ImmutableListOfLists<String>? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
@@ -412,6 +438,23 @@ class UserMetadata {
|
||||
fun lnAddress(): String? {
|
||||
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestUsername(): String? {
|
||||
return name?.ifBlank { null } ?: username?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun bestDisplayName(): String? {
|
||||
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun nip05(): String? {
|
||||
return nip05?.ifBlank { null }
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
if (picture.isNullOrBlank()) picture = null
|
||||
return picture
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
|
||||
@@ -49,6 +49,8 @@ object BlurHashDecoder {
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
@@ -14,6 +15,7 @@ import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.util.Base64
|
||||
|
||||
@Immutable
|
||||
data class CashuToken(
|
||||
val token: String,
|
||||
val mint: String,
|
||||
val totalAmount: Long,
|
||||
val fees: Int,
|
||||
val redeemInvoiceAmount: Long,
|
||||
val proofs: JsonArray
|
||||
)
|
||||
|
||||
class CashuProcessor {
|
||||
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val base64token = cashuToken.replace("cashuA", "")
|
||||
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
|
||||
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
|
||||
val proofs = token["proofs"].asJsonArray
|
||||
val mint = token["mint"].asString
|
||||
|
||||
var totalAmount = 0L
|
||||
for (proof in proofs) {
|
||||
totalAmount += proof.asJsonObject["amount"].asLong
|
||||
}
|
||||
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
|
||||
val redeemInvoiceAmount = totalAmount - fees
|
||||
|
||||
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
|
||||
} catch (e: Exception) {
|
||||
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
runCatching {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lnaddress = lud16,
|
||||
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
|
||||
message = "Reedem Cashu",
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, onSuccess, onError)
|
||||
},
|
||||
onProgress = {
|
||||
},
|
||||
onError = onError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
try {
|
||||
val client = HttpClient.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
|
||||
val jsonObject = JsonObject()
|
||||
jsonObject.add("proofs", token.proofs)
|
||||
jsonObject.addProperty("pr", invoice)
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = jsonObject.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
|
||||
val successful = tree?.get("paid")?.asText() == "true"
|
||||
|
||||
if (successful) {
|
||||
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
|
||||
} else {
|
||||
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Token melt failure: " + e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
object HexValidator {
|
||||
|
||||
private fun isHex2(c: Char): Boolean {
|
||||
return when (c) {
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun isHex(hex: String?): Boolean {
|
||||
if (hex == null) return false
|
||||
var isHex = true
|
||||
for (c in hex.toCharArray()) {
|
||||
if (!isHex2(c)) {
|
||||
isHex = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return isHex
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@@ -18,7 +20,7 @@ class NIP30Parser {
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
var finalList = mutableListOf<String>()
|
||||
val finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
|
||||
@@ -31,7 +31,7 @@ class Nip05Verifier() {
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
@@ -49,6 +49,8 @@ class Nip05Verifier() {
|
||||
|
||||
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
@@ -71,13 +73,15 @@ class Nip05Verifier() {
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
// NIP05 usernames are case insensitive, but JSON properties are not
|
||||
// converts the json to lowercase and then tries to access the username via a
|
||||
// lowercase version of the username.
|
||||
|
||||
@@ -70,6 +70,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.Request
|
||||
|
||||
@Immutable
|
||||
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
|
||||
|
||||
object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(10)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
fun String.isUTF16Char(pos: Int): Boolean {
|
||||
return Character.charCount(this.codePointAt(pos)) == 2
|
||||
}
|
||||
|
||||
fun String.firstFullChar(): String {
|
||||
return when (this.length) {
|
||||
0, 1 -> return this
|
||||
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
|
||||
else -> {
|
||||
val first = isUTF16Char(0)
|
||||
val second = isUTF16Char(2)
|
||||
if (first && second) {
|
||||
this.take(4)
|
||||
} else if (first) {
|
||||
this.take(2)
|
||||
} else {
|
||||
this.take(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,28 +55,32 @@ class LightningAddressResolver() {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onSuccess(it.body.string())
|
||||
} else {
|
||||
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
override fun onFailure(call: Call, e: java.io.IOException) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
e.printStackTrace()
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,5 +46,6 @@ class ChannelCreateEvent(
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
@Immutable
|
||||
data class ChannelData(val name: String?, val about: String?, val picture: String?)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
@@ -12,6 +13,7 @@ import java.util.Date
|
||||
@Immutable
|
||||
data class Contact(val pubKeyHex: String, val relayUri: String?)
|
||||
|
||||
@Stable
|
||||
class ContactListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
|
||||
@@ -55,7 +55,7 @@ open class Event(
|
||||
}
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0].equals("zapraiser", true))
|
||||
(it.size > 1 && it[0] == "zapraiser")
|
||||
}?.get(1)?.toLongOrNull()
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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.security.MessageDigest
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class HTTPAuthorizationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
companion object {
|
||||
const val kind = 27235
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
method: String,
|
||||
body: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): HTTPAuthorizationEvent {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
var hash = ""
|
||||
body?.let {
|
||||
hash = sha256.digest(it.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
val tags = listOfNotNull(
|
||||
listOf("u", url),
|
||||
listOf("method", method),
|
||||
listOf("payload", hash)
|
||||
)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return HTTPAuthorizationEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class LongTextNoteEvent(
|
||||
fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
fun publishedAt() = try {
|
||||
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.Gson
|
||||
@@ -12,9 +13,10 @@ import nostr.postr.Utils
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Date
|
||||
|
||||
@Stable
|
||||
abstract class IdentityClaim(
|
||||
var identity: String,
|
||||
var proof: String
|
||||
val identity: String,
|
||||
val proof: String
|
||||
) {
|
||||
abstract fun toProofUrl(): String
|
||||
abstract fun toIcon(): Int
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
@@ -23,11 +24,18 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
|
||||
|
||||
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
|
||||
fun verifiedRecipientPubKey(): HexKey? {
|
||||
val recipient = recipientPubKey()
|
||||
return if (HexValidator.isHex(recipient)) {
|
||||
recipient
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun talkingWith(oneSideHex: String): HexKey {
|
||||
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
|
||||
|
||||
@@ -17,8 +17,8 @@ class RepostEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun boostedPost() = taggedEvents()
|
||||
fun originalAuthor() = taggedUsers()
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
|
||||
@@ -67,7 +67,7 @@ object Nip19 {
|
||||
}
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
package com.vitorpamplona.amethyst.service.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -33,6 +34,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
private var customActionForDestroy: (() -> Unit)? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "Init TTS")
|
||||
initTTS()
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -7,19 +7,21 @@ 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.model.Account
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
import okio.IOException
|
||||
import okio.source
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
|
||||
object ImageUploader {
|
||||
lateinit var account: Account
|
||||
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
@@ -33,12 +35,11 @@ object ImageUploader {
|
||||
val myContentType = contentType ?: contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = size
|
||||
?: contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: kotlin.runCatching {
|
||||
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
|
||||
|
||||
@@ -58,6 +59,9 @@ object ImageUploader {
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
ServersAvailable.NOSTRCHECK_ME, ServersAvailable.NOSTRCHECK_ME_NIP_94 -> {
|
||||
NostrCheckMeServer()
|
||||
}
|
||||
else -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
@@ -99,7 +103,7 @@ object ImageUploader {
|
||||
)
|
||||
.build()
|
||||
|
||||
server.clientID()?.let {
|
||||
server.clientID(requestBody.toString())?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
@@ -133,6 +137,12 @@ object ImageUploader {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun NIP98Header(url: String, method: String, body: String): String {
|
||||
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
return "Nostr " + encodedNIP98Event
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
@@ -140,7 +150,7 @@ abstract class FileServer {
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(): String? = null
|
||||
open fun clientID(info: String): String? = null
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
@@ -156,7 +166,7 @@ class NostrImgServer : FileServer() {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
@@ -175,7 +185,7 @@ class ImgurServer : FileServer() {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
override fun clientID(info: String) = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
@@ -189,7 +199,7 @@ class NostrBuildServer : FileServer() {
|
||||
return "fileToUpload"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
@@ -203,5 +213,43 @@ class NostrFilesDevServer : FileServer() {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class NostrCheckMeServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("url")?.asText()
|
||||
var id = tree?.get("id")?.asText()
|
||||
var isCompleted = false
|
||||
|
||||
val client = OkHttpClient()
|
||||
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(requrl)
|
||||
.addHeader("Authorization", ImageUploader.NIP98Header(requrl, "GET", ""))
|
||||
.get()
|
||||
.build()
|
||||
|
||||
while (!isCompleted) {
|
||||
val response = client.newCall(request).execute()
|
||||
val body = response.body?.string()
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
isCompleted = tree?.get("status")?.asText() == "completed"
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
override fun clientID(body: String) = ImageUploader.NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ 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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -60,10 +61,11 @@ 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.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -347,6 +349,10 @@ private fun RenderChannel(
|
||||
item: com.vitorpamplona.amethyst.model.Channel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val hasNewMessages = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
@@ -358,7 +364,7 @@ private fun RenderChannel(
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.summary(),
|
||||
false,
|
||||
hasNewMessages,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
@@ -384,7 +390,7 @@ fun UserComposeForChat(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, 55.dp, accountViewModel)
|
||||
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -50,6 +50,8 @@ open class NewMediaModel : ViewModel() {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRCHECK_ME) {
|
||||
selectedServer = ServersAvailable.NOSTRCHECK_ME_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ 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.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -621,7 +622,7 @@ private fun ForwardZapTo(
|
||||
Text(
|
||||
text = stringResource(R.string.zap_forward_lnAddress),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
fontSize = 14.sp
|
||||
fontSize = Font14SP
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
@@ -797,11 +798,13 @@ enum class ServersAvailable {
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
NOSTRCHECK_ME,
|
||||
|
||||
// IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NOSTRCHECK_ME_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@@ -824,10 +827,12 @@ fun ImageVideoDescription(
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME, stringResource(id = R.string.upload_server_nostrcheckme), stringResource(id = R.string.upload_server_nostrcheckme_explainer)),
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRCHECK_ME_NIP_94, stringResource(id = R.string.upload_server_nostrcheckme_nip94), stringResource(id = R.string.upload_server_nostrcheckme_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -29,6 +30,7 @@ import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Paid
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
@@ -59,8 +61,10 @@ 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 com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.lang.Math.round
|
||||
|
||||
@@ -68,9 +72,16 @@ import java.lang.Math.round
|
||||
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(accountViewModel.account)
|
||||
postViewModel.relays.value.forEach { item ->
|
||||
loadRelayInfo(item.url, context, scope) {
|
||||
postViewModel.togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -116,6 +127,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
if (index == 0) {
|
||||
ServerConfigHeader()
|
||||
}
|
||||
|
||||
ServerConfig(
|
||||
item,
|
||||
onToggleDownload = { postViewModel.toggleDownload(it) },
|
||||
@@ -129,7 +141,9 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
|
||||
onDelete = { postViewModel.deleteRelay(it) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
nav = nav,
|
||||
scope = scope,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,7 +181,7 @@ fun ServerConfigHeader() {
|
||||
Text(
|
||||
text = stringResource(R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
@@ -177,7 +191,7 @@ fun ServerConfigHeader() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
@@ -187,7 +201,7 @@ fun ServerConfigHeader() {
|
||||
Text(
|
||||
text = stringResource(R.string.errors),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
@@ -197,7 +211,7 @@ fun ServerConfigHeader() {
|
||||
Text(
|
||||
text = stringResource(R.string.spam),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = Font14SP,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
@@ -228,10 +242,10 @@ fun ServerConfig(
|
||||
|
||||
onDelete: (RelaySetupInfo) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
nav: (String) -> Unit,
|
||||
context: Context,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
|
||||
if (relayInfo != null) {
|
||||
@@ -268,8 +282,19 @@ fun ServerConfig(
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (item.paidRelay) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Paid,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(14.dp),
|
||||
tint = Color.Green
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = item.url.removePrefix("wss://"),
|
||||
text = item.url.removePrefix("wss://").removeSuffix("/"),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
|
||||
@@ -136,6 +136,12 @@ class NewRelayListViewModel : ViewModel() {
|
||||
it.updated(relay, relay.copy(feedTypes = newTypes))
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePaidRelay(relay: RelaySetupInfo, paid: Boolean) {
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(paidRelay = paid))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,6 +30,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
@@ -245,7 +248,11 @@ private fun DisplayOwnerInformation(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadUser(baseUserHex = userHex) {
|
||||
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)
|
||||
Crossfade(it) {
|
||||
if (it != null) {
|
||||
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,15 +311,29 @@ fun loadRelayInfo(
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(it.body.string()))
|
||||
} else {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable: $body", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information),
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
@@ -321,12 +342,12 @@ fun loadRelayInfo(
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information),
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
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.layout.size
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.CashuProcessor
|
||||
import com.vitorpamplona.amethyst.service.CashuToken
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
|
||||
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
|
||||
|
||||
LaunchedEffect(key1 = cashutoken) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newCachuData = CashuProcessor().parse(cashutoken)
|
||||
launch(Dispatchers.Main) {
|
||||
cachuData = newCachuData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = cachuData) {
|
||||
when (it) {
|
||||
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<CashuToken> -> Text(
|
||||
text = "$cashutoken ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
|
||||
val lud16 = remember(accountViewModel) {
|
||||
accountViewModel.account.userProfile().info?.lud16
|
||||
}
|
||||
|
||||
val useWebService = false
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(1.dp, MaterialTheme.colors.subtleBorder, QuoteBorder)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.cashu),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.cashu),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
token.totalAmount.let {
|
||||
Text(
|
||||
text = "$it ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 2.dp),
|
||||
onClick = {
|
||||
if (lud16 != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
CashuProcessor().melt(
|
||||
token,
|
||||
lud16,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"No Lightning Address set",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.cashu_redeem),
|
||||
color = Color.White,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp).padding(horizontal = 1.dp),
|
||||
onClick = {
|
||||
if (useWebService) {
|
||||
// In case we want to use the cashu.me webservice
|
||||
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=false"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
startActivity(context, intent, null)
|
||||
} else {
|
||||
// Copying the token to clipboard for now
|
||||
var orignaltoken = token.token
|
||||
clipboardManager.setText(AnnotatedString("$orignaltoken"))
|
||||
scope.launch {
|
||||
Toast.makeText(context, "Copied token to clipboard", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text("⎘", color = Color.White, fontSize = 18.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,8 @@ private fun LoadNote(
|
||||
) {
|
||||
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
|
||||
|
||||
LaunchedEffect(key1 = hex) {
|
||||
if (noteBase == null) {
|
||||
if (noteBase == null) {
|
||||
LaunchedEffect(key1 = hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(hex)
|
||||
}
|
||||
@@ -186,8 +186,8 @@ private fun DisplayAddress(
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
if (noteBase == null) {
|
||||
if (noteBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
@@ -225,8 +225,8 @@ private fun DisplayUser(
|
||||
) {
|
||||
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
if (userBase == null) {
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
@@ -396,8 +396,8 @@ fun CreateTextWithEmoji(
|
||||
) {
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
if (emojis.isNotEmpty()) {
|
||||
if (emojis.isNotEmpty()) {
|
||||
LaunchedEffect(key1 = text) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -25,13 +26,16 @@ fun MayBeWithdrawal(lnurlWord: String) {
|
||||
}
|
||||
}
|
||||
|
||||
lnWithdrawal?.let {
|
||||
ClickableWithdrawal(withdrawalString = it)
|
||||
Crossfade(targetState = lnWithdrawal) {
|
||||
if (it != null) {
|
||||
ClickableWithdrawal(withdrawalString = it)
|
||||
} else {
|
||||
Text(
|
||||
text = "$lnurlWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnurlWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
sealed class GenericLoadable<T> {
|
||||
|
||||
@Immutable
|
||||
class Loading<T> : GenericLoadable<T>()
|
||||
|
||||
@Immutable
|
||||
class Loaded<T>(val loaded: T) : GenericLoadable<T>()
|
||||
|
||||
@Immutable
|
||||
class Empty<T> : GenericLoadable<T>()
|
||||
|
||||
@Immutable
|
||||
class Error<T>(val errorMessage: String) : GenericLoadable<T>()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -27,7 +28,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
@@ -35,7 +36,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount = try {
|
||||
@@ -50,13 +51,16 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
}
|
||||
}
|
||||
|
||||
lnInvoice?.let {
|
||||
InvoicePreview(it.first, it.second)
|
||||
Crossfade(targetState = lnInvoice) {
|
||||
if (it != null) {
|
||||
InvoicePreview(it.first, it.second)
|
||||
} else {
|
||||
Text(
|
||||
text = "$lnbcWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnbcWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
|
||||
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import id.zelory.compressor.Compressor
|
||||
import id.zelory.compressor.constraint.default
|
||||
import java.io.File
|
||||
@@ -24,6 +25,8 @@ class MediaCompressor {
|
||||
onReady: (Uri, String?, Long?) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (contentType?.startsWith("video", true) == true) {
|
||||
VideoCompressor.start(
|
||||
context = applicationContext, // => This is required
|
||||
|
||||
@@ -3,8 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
@@ -17,38 +16,32 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.HeadingStyle
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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.theme.subtleBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
@@ -89,38 +82,6 @@ fun isValidURL(url: String?): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle ->
|
||||
when (level) {
|
||||
0 -> textStyle.copy(
|
||||
fontSize = 30.sp,
|
||||
fontWeight = FontWeight.Light
|
||||
)
|
||||
1 -> textStyle.copy(
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Light
|
||||
)
|
||||
2 -> textStyle.copy(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Light
|
||||
)
|
||||
3 -> textStyle.copy(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
4 -> textStyle.copy(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
5 -> textStyle.copy(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
else -> textStyle
|
||||
}
|
||||
}
|
||||
|
||||
internal val DefaultParagraphSpacing: TextUnit = 12.sp
|
||||
val richTextDefaults = RichTextStyle().resolveDefaults()
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
@@ -159,7 +120,6 @@ data class RichTextViewerState(
|
||||
val customEmoji: ImmutableMap<String, String>
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
@@ -262,7 +222,7 @@ private fun parseUrls(
|
||||
}
|
||||
|
||||
enum class WordType {
|
||||
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
|
||||
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -286,6 +246,8 @@ private fun RenderWord(
|
||||
WordType.INVOICE
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WordType.WITHDRAW
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
WordType.CASHU
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
WordType.EMAIL
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
@@ -341,6 +303,7 @@ private fun RenderWordWithoutPreview(
|
||||
WordType.INVOICE -> NormalWord(wordSpace)
|
||||
// Don't offer to withdraw
|
||||
WordType.WITHDRAW -> NormalWord(wordSpace)
|
||||
WordType.CASHU -> NormalWord(wordSpace)
|
||||
WordType.EMAIL -> ClickableEmail(word)
|
||||
WordType.PHONE -> ClickablePhone(word)
|
||||
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
|
||||
@@ -371,6 +334,7 @@ private fun RenderWordWithPreview(
|
||||
WordType.EMOJI -> RenderCustomEmoji(word, state)
|
||||
WordType.INVOICE -> MayBeInvoicePreview(word)
|
||||
WordType.WITHDRAW -> MayBeWithdrawal(word)
|
||||
WordType.CASHU -> CashuPreview(word, accountViewModel)
|
||||
WordType.EMAIL -> ClickableEmail(word)
|
||||
WordType.PHONE -> ClickablePhone(word)
|
||||
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
|
||||
@@ -396,6 +360,9 @@ private fun NormalWord(word: String) {
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class UrlWithExtraChars(val url: String, val extraChars: String)
|
||||
|
||||
@Composable
|
||||
private fun NoProtocolUrlRenderer(word: String) {
|
||||
val wordSpace = remember(word) {
|
||||
@@ -403,7 +370,7 @@ private fun NoProtocolUrlRenderer(word: String) {
|
||||
}
|
||||
|
||||
var linkedUrl by remember(word) {
|
||||
mutableStateOf<Pair<String, String>?>(null)
|
||||
mutableStateOf<UrlWithExtraChars?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = word) {
|
||||
@@ -413,19 +380,30 @@ private fun NoProtocolUrlRenderer(word: String) {
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
linkedUrl = Pair(url, additionalChars)
|
||||
launch(Dispatchers.Main) {
|
||||
linkedUrl = UrlWithExtraChars(url, additionalChars)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedUrl != null) {
|
||||
ClickableUrl(linkedUrl!!.first, "https://${linkedUrl!!.first}")
|
||||
Text("${linkedUrl!!.second} ")
|
||||
} else {
|
||||
Text(
|
||||
text = wordSpace,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
Crossfade(targetState = linkedUrl) {
|
||||
if (it != null) {
|
||||
RenderUrl(it)
|
||||
} else {
|
||||
Text(
|
||||
text = wordSpace,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderUrl(it: UrlWithExtraChars) {
|
||||
Row() {
|
||||
ClickableUrl(it.url, "https://${it.url}")
|
||||
Text("${it.extraChars} ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,56 +417,22 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
paragraphSpacing = DefaultParagraphSpacing,
|
||||
headingStyle = DefaultHeadingStyle,
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor.value)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
|
||||
.compositeOver(backgroundColor.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick = { link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
val onClick = remember {
|
||||
{ link: String ->
|
||||
val route = uriToRoute(link)
|
||||
if (route != null) {
|
||||
nav(route)
|
||||
} else {
|
||||
runCatching { uri.openUri(link) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
ProvideTextStyle(TextStyle(lineHeight = 1.30.em)) {
|
||||
ProvideTextStyle(MarkdownTextStyle) {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
style = MaterialTheme.colors.markdownStyle
|
||||
) {
|
||||
RefreshableContent(content, tags) {
|
||||
Markdown(
|
||||
@@ -550,12 +494,12 @@ private fun ObserveNIP19Event(
|
||||
) {
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (baseNote == null) {
|
||||
if (baseNote == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNote = note
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,12 +531,12 @@ private fun ObserveNIP19User(
|
||||
) {
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
if (baseUser == null) {
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
baseUser = user
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -785,26 +729,34 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
|
||||
}
|
||||
}
|
||||
|
||||
loadedLink = LoadedBechLink(returningNote, it)
|
||||
val newLink = LoadedBechLink(returningNote, it)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
loadedLink = newLink
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canPreview) {
|
||||
loadedLink?.let { loadedLink ->
|
||||
loadedLink.baseNote?.let {
|
||||
DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink)
|
||||
} ?: run {
|
||||
ClickableRoute(loadedLink.nip19, nav)
|
||||
Crossfade(targetState = loadedLink) {
|
||||
if (canPreview && it?.baseNote != null) {
|
||||
Row() {
|
||||
DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it)
|
||||
}
|
||||
} ?: run {
|
||||
Text(text = remember { "$word " })
|
||||
}
|
||||
} else {
|
||||
loadedLink?.let {
|
||||
ClickableRoute(it.nip19, nav)
|
||||
} ?: run {
|
||||
Text(text = remember { "$word " })
|
||||
} else if (it?.nip19 != null) {
|
||||
Row() {
|
||||
ClickableRoute(it.nip19, nav)
|
||||
}
|
||||
} else {
|
||||
val text = remember {
|
||||
if (word.length > 16) {
|
||||
word.replaceRange(8, word.length - 8, ":")
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = text, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -817,24 +769,10 @@ private fun DisplayFullNote(
|
||||
nav: (String) -> Unit,
|
||||
loadedLink: LoadedBechLink
|
||||
) {
|
||||
val borderColor = MaterialTheme.colors.subtleBorder
|
||||
|
||||
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,
|
||||
modifier = MaterialTheme.colors.replyModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
nav = nav
|
||||
@@ -855,9 +793,11 @@ private fun DisplayFullNote(
|
||||
}
|
||||
}
|
||||
|
||||
data class HashWordWithExtra(val hashtag: String, val extras: String?)
|
||||
|
||||
@Composable
|
||||
fun HashTag(word: String, nav: (String) -> Unit) {
|
||||
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
var tagSuffixPair by remember { mutableStateOf<HashWordWithExtra?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = word) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -872,66 +812,82 @@ fun HashTag(word: String, nav: (String) -> Unit) {
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
tagSuffixPair = Pair(myTag, mySuffix)
|
||||
launch(Dispatchers.Main) {
|
||||
tagSuffixPair = HashWordWithExtra(myTag, mySuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tagSuffixPair?.let { tagPair ->
|
||||
val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) }
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("#${tagPair.first}")
|
||||
}
|
||||
},
|
||||
onClick = { nav("Hashtag/${tagPair.first}") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
Crossfade(targetState = tagSuffixPair) {
|
||||
if (it != null) {
|
||||
Row() {
|
||||
RenderHashtag(it, nav)
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
} else {
|
||||
Text(text = remember { "$word " })
|
||||
}
|
||||
tagPair.second?.ifBlank { "" }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
} ?: Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderHashtag(
|
||||
tagPair: HashWordWithExtra,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val primary = MaterialTheme.colors.primary
|
||||
val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag, primary) }
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("#${tagPair.hashtag}")
|
||||
}
|
||||
},
|
||||
onClick = { nav("Hashtag/${tagPair.hashtag}") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
}
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = mapOf(
|
||||
myId to InlineIcon(hashtagIcon)
|
||||
)
|
||||
)
|
||||
}
|
||||
tagPair.extras?.ifBlank { "" }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineIcon(hashtagIcon: HashtagIcon) =
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
|
||||
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
|
||||
|
||||
@Composable
|
||||
@@ -956,11 +912,15 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
loadedTag = LoadedTag(it, null, suffix)
|
||||
launch(Dispatchers.Main) {
|
||||
loadedTag = LoadedTag(it, null, suffix)
|
||||
}
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
loadedTag = LoadedTag(null, it, suffix)
|
||||
launch(Dispatchers.Main) {
|
||||
loadedTag = LoadedTag(null, it, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -969,19 +929,28 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedTag == null) {
|
||||
Text(
|
||||
text = remember {
|
||||
"$word "
|
||||
Crossfade(targetState = loadedTag) {
|
||||
if (it == null) {
|
||||
Text(
|
||||
text = remember {
|
||||
"$word "
|
||||
}
|
||||
)
|
||||
} else if (it.user != null) {
|
||||
Row() {
|
||||
DisplayUserFromTag(it.user, loadedTag?.addedChars ?: "", nav)
|
||||
}
|
||||
} else if (it.note != null) {
|
||||
Row() {
|
||||
DisplayNoteFromTag(
|
||||
it.note,
|
||||
loadedTag?.addedChars ?: "",
|
||||
canPreview,
|
||||
accountViewModel,
|
||||
backgroundColor,
|
||||
nav
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
loadedTag?.user?.let {
|
||||
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
|
||||
}
|
||||
|
||||
loadedTag?.note?.let {
|
||||
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -999,15 +968,7 @@ private fun DisplayNoteFromTag(
|
||||
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.subtleBorder,
|
||||
QuoteBorder
|
||||
),
|
||||
modifier = MaterialTheme.colors.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
nav = nav
|
||||
@@ -1027,23 +988,27 @@ private fun DisplayUserFromTag(
|
||||
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()
|
||||
}
|
||||
val route = remember { "User/${baseUser.pubkeyHex}" }
|
||||
val suffix = remember { "$addedChars " }
|
||||
val hex = remember { baseUser.pubkeyDisplayHex() }
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
suffix = remember { "$addedChars " },
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
tags = userTags
|
||||
)
|
||||
val meta by baseUser.live().metadata.map {
|
||||
it.user.info
|
||||
}.distinctUntilChanged().observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = meta) {
|
||||
Row() {
|
||||
val displayName = remember(it) {
|
||||
it?.bestDisplayName() ?: hex
|
||||
}
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
suffix = suffix,
|
||||
maxLines = 1,
|
||||
route = route,
|
||||
nav = nav,
|
||||
tags = it?.tags
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import coil.ImageLoader
|
||||
@@ -31,6 +33,8 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
|
||||
}
|
||||
|
||||
private fun svgString(msg: String): String {
|
||||
checkNotInMainThread()
|
||||
|
||||
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])
|
||||
@@ -58,6 +62,7 @@ private fun svgString(msg: String): String {
|
||||
</svg>"""
|
||||
}
|
||||
|
||||
@Stable
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
private val data: Uri
|
||||
@@ -93,6 +98,7 @@ object Robohash {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
private data class Part(val style: String, val paths: String)
|
||||
|
||||
private const val background = """<polyline class="cls-bg" points="150.3 7.4 55.1 97.9 55.1 203.1 150.3 293.6 245.9 203.1 245.9 97.9 150.3 7.4"/>"""
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
@@ -48,12 +47,10 @@ fun RobohashAsyncImage(
|
||||
)
|
||||
}
|
||||
|
||||
var imageErrors = mapOf<String, Long>()
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
robot: String,
|
||||
model: String,
|
||||
model: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
@@ -62,42 +59,24 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val errorCache = remember(imageErrors) { imageErrors[model] }
|
||||
val context = LocalContext.current
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(context, robot)
|
||||
)
|
||||
|
||||
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(context, robot)
|
||||
)
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
onError = {
|
||||
imageErrors = imageErrors + Pair(model, Date().time / 1000)
|
||||
}
|
||||
)
|
||||
}
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -112,28 +91,15 @@ fun RobohashAsyncImageProxy(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (model == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,19 +33,34 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun SensitivityWarning(
|
||||
hasSensitiveContent: Boolean,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
|
||||
|
||||
if (hasSensitiveContent) {
|
||||
SensitivityWarning(accountViewModel, content)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SensitivityWarning(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
var showContentWarningNote by remember(accountState) {
|
||||
mutableStateOf(accountState?.account?.showSensitiveContent != true && hasSensitiveContent)
|
||||
mutableStateOf(accountState?.account?.showSensitiveContent != true)
|
||||
}
|
||||
|
||||
if (showContentWarningNote) {
|
||||
|
||||
@@ -29,8 +29,8 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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 com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
@@ -120,7 +120,7 @@ fun SpinnerSelectionDialog(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray, fontSize = 14.sp)
|
||||
Text(text = it, color = Color.Gray, fontSize = Font14SP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ fun UrlPreview(url: String, urlText: String) {
|
||||
}
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
LaunchedEffect(url) {
|
||||
launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(url) {
|
||||
urlPreviewState = it
|
||||
launch(Dispatchers.Main) {
|
||||
urlPreviewState = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
public var DefaultMutedSetting = mutableStateOf(true)
|
||||
|
||||
@@ -97,12 +99,26 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
VideoView1(videoUri, description, thumb, onDialog)
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView1(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
|
||||
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
@@ -127,6 +143,7 @@ fun VideoView(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun VideoView(
|
||||
videoUri: String,
|
||||
@@ -135,6 +152,21 @@ fun VideoView(
|
||||
defaultToStart: Boolean = false,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog)
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView1(
|
||||
videoUri: String,
|
||||
description: String? = null,
|
||||
exoPlayerData: VideoPlayer,
|
||||
defaultToStart: Boolean = false,
|
||||
thumb: VideoThumb? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
|
||||
@@ -4,7 +4,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.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
@@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
@@ -33,12 +33,10 @@ fun ZapRaiserRequest(
|
||||
newPostViewModel: NewPostViewModel
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -49,7 +47,7 @@ fun ZapRaiserRequest(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.lightning),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -22,9 +22,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -51,6 +54,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
@@ -73,9 +77,8 @@ import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
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.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.hashVerified
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -175,14 +178,7 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.subtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
var mainImageModifier = MaterialTheme.colors.imageModifier
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
@@ -448,13 +444,34 @@ private fun DisplayBlurHash(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
|
||||
val view = LocalView.current
|
||||
|
||||
DisposableEffect(key1 = Unit) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.hide(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.show(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column() {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
val pagerState: PagerState = rememberPagerState()
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
@@ -463,6 +480,18 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
@@ -479,18 +508,6 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
|
||||
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
|
||||
val publicChannels = account.followingChannels().map { it ->
|
||||
it.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter { account.isAcceptable(it) && it.event != null }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.lastOrNull { it.event != null }
|
||||
.lastOrNull()
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels)
|
||||
|
||||
@@ -26,6 +26,8 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
@@ -33,6 +35,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
((it.event?.createdAt() ?: 0) < now) &&
|
||||
!it.isNewThread()
|
||||
}
|
||||
.toSet()
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import okhttp3.Request
|
||||
import java.util.Date
|
||||
|
||||
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -35,13 +32,13 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val fortyEightHrs = (Date().time / 1000) - 60 * 60 * 48 // hrs
|
||||
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > fortyEightHrs && noteEvent.status() == "live" && checkIfOnline(noteEvent.streaming())) &&
|
||||
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
|
||||
@@ -55,20 +52,3 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIfOnline(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
|
||||
return try {
|
||||
HttpClient.getHttpClient().newCall(request).execute().code == 200
|
||||
} catch (e: Exception) {
|
||||
Log.e("LiveActivities", "Failed to check streaming url $url", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import java.util.Date
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -31,6 +32,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val oneMinuteInTheFuture = Date().time / 1000 + (1 * 60) // one minute in the future.
|
||||
val oneHr = 60 * 60
|
||||
|
||||
return collection
|
||||
@@ -41,6 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
|
||||
it.isNewThread() &&
|
||||
(
|
||||
(noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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.PeopleListEvent
|
||||
|
||||
object PeopleListFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
val lists = LocalCache.addressables.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is PeopleListEvent)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
return lists
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -5,25 +5,17 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
|
||||
val notes = user.latestBookmarkList?.taggedEvents()?.mapNotNull {
|
||||
LocalCache.checkGetOrCreateNote(it)
|
||||
}?.toSet() ?: emptySet()
|
||||
|
||||
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
|
||||
val addresses = user.latestBookmarkList?.taggedAddresses()?.map {
|
||||
LocalCache.getOrCreateAddressableNote(it)
|
||||
}?.toSet() ?: emptySet()
|
||||
|
||||
|
||||
@@ -4,23 +4,15 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
class UserProfileConversationsFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user?.notes
|
||||
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
return user.notes
|
||||
.filter { account.isAcceptable(it) == true && !it.isNewThread() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,19 @@ import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
|
||||
}
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
|
||||
|
||||
return user?.notes
|
||||
?.plus(longFormNotes)
|
||||
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
return user.notes
|
||||
.plus(longFormNotes)
|
||||
.filter { account.isAcceptable(it) == true && it.isNewThread() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,13 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
|
||||
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
var user: User? = null
|
||||
|
||||
class UserProfileReportsFeedFilter(val user: User) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return user?.pubkeyHex ?: ""
|
||||
}
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
return user.pubkeyHex ?: ""
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val myUser = user ?: return emptyList()
|
||||
|
||||
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(myUser.pubkeyHex) == true }
|
||||
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(user.pubkeyHex) == true }
|
||||
|
||||
return reportNotes
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
|
||||
@@ -8,12 +8,11 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -35,7 +34,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -43,6 +41,7 @@ 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
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -57,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -130,23 +130,28 @@ fun DisplayAccount(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
var baseUser by remember { mutableStateOf<User?>(null) }
|
||||
var baseUser by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(decodePublicKey(acc.npub).toHexKey())) }
|
||||
|
||||
LaunchedEffect(key1 = acc.npub) {
|
||||
launch(Dispatchers.IO) {
|
||||
baseUser = try {
|
||||
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = acc.npub) {
|
||||
launch(Dispatchers.IO) {
|
||||
baseUser = try {
|
||||
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseUser?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
}.padding(16.dp, 16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
}
|
||||
.padding(16.dp, 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
@@ -158,7 +163,9 @@ fun DisplayAccount(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(55.dp).padding(0.dp)
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
AccountPicture(it)
|
||||
}
|
||||
@@ -196,21 +203,15 @@ private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun AccountPicture(user: User) {
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
val profilePicture by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.profilePicture()
|
||||
}
|
||||
}
|
||||
val profilePicture by user.live().metadata.map {
|
||||
it.user.profilePicture()
|
||||
}.observeAsState()
|
||||
|
||||
RobohashAsyncImageProxy(
|
||||
robot = remember(user) { user.pubkeyHex },
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
modifier = AccountPictureModifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,17 +220,13 @@ private fun AccountName(
|
||||
acc: AccountInfo,
|
||||
user: User
|
||||
) {
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
val displayName by remember(userState) {
|
||||
derivedStateOf {
|
||||
user.bestDisplayName()
|
||||
}
|
||||
}
|
||||
val tags by remember(userState) {
|
||||
derivedStateOf {
|
||||
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}
|
||||
}
|
||||
val displayName by user.live().metadata.map {
|
||||
user.bestDisplayName()
|
||||
}.observeAsState()
|
||||
|
||||
val tags by user.live().metadata.map {
|
||||
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}.observeAsState()
|
||||
|
||||
displayName?.let {
|
||||
CreateTextWithEmoji(
|
||||
@@ -250,8 +247,42 @@ private fun LogoutButton(
|
||||
acc: AccountInfo,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
var logoutDialog by remember { mutableStateOf(false) }
|
||||
if (logoutDialog) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.log_out))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out))
|
||||
},
|
||||
onDismissRequest = {
|
||||
logoutDialog = false
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
logoutDialog = false
|
||||
accountStateViewModel.logOff(acc.npub)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.log_out))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
logoutDialog = false
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { accountStateViewModel.logOff(acc.npub) }
|
||||
onClick = { logoutDialog = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Logout,
|
||||
|
||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
@@ -133,13 +134,9 @@ fun NoTopBar() {
|
||||
@Composable
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultStoriesFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
val list by accountViewModel.accountLiveData.map {
|
||||
it.account.defaultStoriesFollowList
|
||||
}.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
@@ -154,13 +151,9 @@ fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState
|
||||
@Composable
|
||||
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultHomeFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
val list by accountViewModel.accountLiveData.map {
|
||||
it.account.defaultHomeFollowList
|
||||
}.observeAsState(KIND3_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
@@ -175,13 +168,9 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
|
||||
@Composable
|
||||
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultNotificationFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
val list by accountViewModel.accountLiveData.map {
|
||||
it.account.defaultNotificationFollowList
|
||||
}.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
@@ -425,6 +414,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "App Top Bar")
|
||||
refresh()
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
|
||||
@@ -121,7 +121,6 @@ fun ProfileContent(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val accountUserState by baseAccountUser.live().metadata.observeAsState()
|
||||
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
|
||||
|
||||
val profilePubHex = remember(accountUserState) { accountUserState?.user?.pubkeyHex } ?: return
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
@@ -165,6 +166,8 @@ object HomeLatestItem : LatestItem() {
|
||||
account: Account,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
val lastTime = account.loadLastRead("HomeFollows")
|
||||
|
||||
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
|
||||
@@ -178,6 +181,8 @@ object NotificationLatestItem : LatestItem() {
|
||||
account: Account,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
val lastTime = account.loadLastRead("Notification")
|
||||
|
||||
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
|
||||
@@ -191,6 +196,8 @@ object MessagesLatestItem : LatestItem() {
|
||||
account: Account,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
// Checks if the current newest item is still unread.
|
||||
// If so, there is no need to check anything else
|
||||
if (isNew(getNewestItem(account), account)) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
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
|
||||
@@ -39,8 +42,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -49,7 +54,14 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -61,33 +73,73 @@ fun ChatroomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val channelHex by remember(noteState) {
|
||||
derivedStateOf {
|
||||
noteState?.note?.channelHex()
|
||||
}
|
||||
}
|
||||
val isBlank = remember(noteState) {
|
||||
note?.event == null
|
||||
}
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.observeAsState(baseNote.event == null)
|
||||
|
||||
if (isBlank) {
|
||||
BlankNote(Modifier)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex!!) { channel ->
|
||||
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val userRoomHex = remember(noteState, accountViewModel) {
|
||||
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
|
||||
} ?: return
|
||||
ChatroomComposeChannelOrUser(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
LoadUser(userRoomHex) { user ->
|
||||
UserRoomCompose(baseNote, user, accountViewModel, nav)
|
||||
@Composable
|
||||
fun ChatroomComposeChannelOrUser(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val channelHex by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.channelHex()
|
||||
}
|
||||
}
|
||||
|
||||
if (channelHex != null) {
|
||||
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
ChatroomDirectMessage(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatroomDirectMessage(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val userRoomHex by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
userRoomHex?.let {
|
||||
LoadUser(it) { baseUser ->
|
||||
Crossfade(baseUser) { user ->
|
||||
if (user != null) {
|
||||
UserRoomCompose(baseNote, user, accountViewModel, nav)
|
||||
} else {
|
||||
Box(Modifier.height(Size75dp).fillMaxWidth()) {
|
||||
// Makes sure just a max amount of objects are loaded.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatroomChannel(
|
||||
channelHex: HexKey?,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadChannel(baseChannelHex = channelHex!!) { channel ->
|
||||
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -130,11 +182,11 @@ private fun ChannelRoomCompose(
|
||||
noteEvent?.content()
|
||||
}
|
||||
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages != newHasNewMessages) {
|
||||
hasNewMessages = newHasNewMessages
|
||||
if (hasNewMessages.value != newHasNewMessages) {
|
||||
hasNewMessages.value = newHasNewMessages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,29 +243,41 @@ private fun UserRoomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
val route = remember(user) {
|
||||
"Room/${user.pubkeyHex}"
|
||||
}
|
||||
|
||||
val createAt by remember(note) {
|
||||
derivedStateOf {
|
||||
note.createdAt()
|
||||
}
|
||||
}
|
||||
|
||||
val content by remember(note) {
|
||||
derivedStateOf {
|
||||
accountViewModel.decrypt(note)
|
||||
}
|
||||
}
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages != newHasNewMessages) {
|
||||
hasNewMessages = newHasNewMessages
|
||||
if (hasNewMessages.value != newHasNewMessages) {
|
||||
hasNewMessages.value = newHasNewMessages
|
||||
}
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
UserPicture(
|
||||
NonClickableUserPicture(
|
||||
baseUser = user,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size55dp
|
||||
)
|
||||
},
|
||||
channelTitle = { UsernameDisplay(user, it) },
|
||||
channelLastTime = remember(note) { note.createdAt() },
|
||||
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { nav(route) }
|
||||
)
|
||||
@@ -239,22 +303,20 @@ private fun WatchNotificationChanges(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
|
||||
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
|
||||
var user by remember(baseUserHex) {
|
||||
mutableStateOf<User?>(LocalCache.getUserIfExists(baseUserHex))
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
if (user == null) {
|
||||
if (user == null) {
|
||||
LaunchedEffect(key1 = baseUserHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
user = LocalCache.checkGetOrCreateUser(baseUserHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user?.let {
|
||||
content(it)
|
||||
}
|
||||
content(user)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -264,7 +326,7 @@ fun ChannelName(
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
hasNewMessages: Boolean,
|
||||
hasNewMessages: MutableState<Boolean>,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ChannelName(
|
||||
@@ -295,86 +357,96 @@ fun ChannelName(
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
hasNewMessages: Boolean,
|
||||
hasNewMessages: MutableState<Boolean>,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
|
||||
Row(
|
||||
modifier = remember { Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) }
|
||||
) {
|
||||
channelPicture()
|
||||
Row(modifier = ChatHeadlineBorders) {
|
||||
Column(Size55Modifier) {
|
||||
channelPicture()
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = remember { Modifier.padding(start = 10.dp) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
FirstRow(channelTitle, channelLastTime)
|
||||
SecondRow(channelLastContent, hasNewMessages)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) })
|
||||
}
|
||||
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecondRow(channelLastContent, hasNewMessages, remember { Modifier.weight(1f) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = remember { Modifier.padding(top = 10.dp) },
|
||||
thickness = 0.25.dp
|
||||
modifier = StdTopPadding,
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SecondRow(channelLastContent: String?, hasNewMessages: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (channelLastContent != null) {
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
private fun SecondRow(channelLastContent: String?, hasNewMessages: MutableState<Boolean>, modifier: Modifier) {
|
||||
if (channelLastContent != null) {
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
if (hasNewMessages) {
|
||||
NewItemsBubble()
|
||||
}
|
||||
if (hasNewMessages.value) {
|
||||
NewItemsBubble()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FirstRow(
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?
|
||||
channelLastTime: Long?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.padding(bottom = 4.dp) }
|
||||
) {
|
||||
channelTitle(
|
||||
remember {
|
||||
Modifier.weight(1f)
|
||||
}
|
||||
)
|
||||
channelTitle(modifier)
|
||||
TimeAgo(channelLastTime)
|
||||
}
|
||||
|
||||
channelLastTime?.let {
|
||||
val context = LocalContext.current
|
||||
val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) }
|
||||
Text(
|
||||
timeAgo,
|
||||
color = MaterialTheme.colors.grayText
|
||||
)
|
||||
@Composable
|
||||
private fun TimeAgo(channelLastTime: Long?) {
|
||||
if (channelLastTime == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val timeAgo by remember(channelLastTime) {
|
||||
derivedStateOf {
|
||||
timeAgo(channelLastTime, context)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = timeAgo,
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -393,6 +465,7 @@ fun NewItemsBubble() {
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.Center)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,8 +26,8 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -46,10 +48,11 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
@@ -68,22 +71,23 @@ 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.ChatBubbleMaxSizeModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size13dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -97,30 +101,33 @@ fun ChatroomMessageCompose(
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val noteEvent = remember(noteState) { noteState?.note?.event }
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.observeAsState(baseNote.event == null)
|
||||
|
||||
if (noteEvent == null) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
}
|
||||
Crossfade(targetState = isBlank) {
|
||||
if (it) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
Modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenChatMessage(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenChatMessage(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,32 +198,34 @@ fun LoadedChatMessageCompose(
|
||||
}
|
||||
}
|
||||
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
innerQuote,
|
||||
nav,
|
||||
onClick = { showReportedNote = true }
|
||||
)
|
||||
} else {
|
||||
val canPreview by remember(state, showReportedNote) {
|
||||
derivedStateOf {
|
||||
(!state.isAcceptable && showReportedNote) || state.canPreview
|
||||
Crossfade(targetState = showHiddenNote) {
|
||||
if (it) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
accountViewModel,
|
||||
Modifier,
|
||||
innerQuote,
|
||||
nav,
|
||||
onClick = { showReportedNote = true }
|
||||
)
|
||||
} else {
|
||||
val canPreview by remember(state, showReportedNote) {
|
||||
derivedStateOf {
|
||||
(!state.isAcceptable && showReportedNote) || state.canPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
NormalChatNote(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
innerQuote,
|
||||
canPreview,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +243,7 @@ fun NormalChatNote(
|
||||
) {
|
||||
val drawAuthorInfo by remember {
|
||||
derivedStateOf {
|
||||
(innerQuote || !accountViewModel.isLoggedUser(note.author)) && note.event !is PrivateDmEvent
|
||||
note.event !is PrivateDmEvent && (innerQuote || !accountViewModel.isLoggedUser(note.author))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,11 +302,11 @@ fun NormalChatNote(
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
val availableBubbleSize = remember { mutableStateOf(IntSize.Zero) }
|
||||
val availableBubbleSize = remember { mutableStateOf(0) }
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val modif2 = remember {
|
||||
if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
|
||||
if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
|
||||
}
|
||||
|
||||
val clickableModifier = remember {
|
||||
@@ -315,7 +324,9 @@ fun NormalChatNote(
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize.value = it
|
||||
if (availableBubbleSize.value != it.width) {
|
||||
availableBubbleSize.value = it.width
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
@@ -357,17 +368,19 @@ private fun RenderBubble(
|
||||
backgroundBubbleColor: MutableState<Color>,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
canPreview: Boolean,
|
||||
availableBubbleSize: MutableState<IntSize>,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val bubbleSize = remember { mutableStateOf(IntSize.Zero) }
|
||||
val bubbleSize = remember { mutableStateOf(0) }
|
||||
|
||||
val bubbleModifier = remember {
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize.value = it
|
||||
if (bubbleSize.value != it.width) {
|
||||
bubbleSize.value = it.width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,8 +412,8 @@ private fun MessageBubbleLines(
|
||||
accountViewModel: AccountViewModel,
|
||||
onWantsToReply: (Note) -> Unit,
|
||||
canPreview: Boolean,
|
||||
bubbleSize: MutableState<IntSize>,
|
||||
availableBubbleSize: MutableState<IntSize>
|
||||
bubbleSize: MutableState<Int>,
|
||||
availableBubbleSize: MutableState<Int>
|
||||
) {
|
||||
if (drawAuthorInfo) {
|
||||
DrawAuthorInfo(
|
||||
@@ -453,7 +466,7 @@ private fun RenderReplyRow(
|
||||
) {
|
||||
val hasReply by remember {
|
||||
derivedStateOf {
|
||||
innerQuote && note.replyTo?.lastOrNull() != null
|
||||
!innerQuote && note.replyTo?.lastOrNull() != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,23 +536,22 @@ private fun NoteRow(
|
||||
|
||||
@Composable
|
||||
private fun ConstrainedStatusRow(
|
||||
bubbleSize: MutableState<IntSize>,
|
||||
availableBubbleSize: MutableState<IntSize>,
|
||||
bubbleSize: MutableState<Int>,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.value.width.toDp(),
|
||||
availableBubbleSize.value.width.toDp()
|
||||
)
|
||||
}
|
||||
)
|
||||
modifier = with(LocalDensity.current) {
|
||||
Modifier
|
||||
.height(26.dp)
|
||||
.padding(top = 5.dp)
|
||||
.widthIn(
|
||||
bubbleSize.value.toDp(),
|
||||
availableBubbleSize.value.toDp()
|
||||
)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -552,29 +564,30 @@ private fun StatusRow(
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
ChatTimeAgo(baseNote)
|
||||
RelayBadges(baseNote, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
ReplyReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colors.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSize = Size16dp
|
||||
iconSize = Size15dp
|
||||
) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,10 +623,8 @@ private fun RenderRegularTextNote(
|
||||
val modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
|
||||
if (eventContent != null) {
|
||||
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
|
||||
|
||||
SensitivityWarning(
|
||||
hasSensitiveContent = hasSensitiveContent,
|
||||
note = note,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
@@ -796,60 +807,71 @@ private fun DisplayMessageUsername(
|
||||
DrawPlayName(userDisplayName)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RelayBadgesState(
|
||||
val shouldDisplayExpandButton: Boolean,
|
||||
val noteRelays: ImmutableList<String>,
|
||||
val noteRelaysSimple: ImmutableList<String>
|
||||
)
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
|
||||
RenderRelayList(baseNote, expanded, accountViewModel, nav)
|
||||
|
||||
RenderExpandButton(baseNote, expanded) {
|
||||
ChatRelayExpandButton { expanded.value = true }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelays by baseNote.live().relays.map {
|
||||
it.note.relays
|
||||
}.observeAsState(baseNote.relays)
|
||||
|
||||
val state: RelayBadgesState by remember(noteRelaysState) {
|
||||
val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3
|
||||
val noteRelays = noteRelaysState?.note?.relays?.toImmutableList() ?: persistentListOf()
|
||||
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toImmutableList() ?: persistentListOf()
|
||||
|
||||
mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple))
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val relaysToDisplay by remember(noteRelaysState) {
|
||||
derivedStateOf {
|
||||
if (expanded) state.noteRelays else state.noteRelaysSimple
|
||||
FlowRow(StdStartPadding) {
|
||||
val relaysToDisplay = remember(noteRelays, expanded.value) {
|
||||
if (expanded.value) noteRelays else noteRelays.take(3)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(Modifier.padding(start = 10.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
RenderRelay(it, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.shouldDisplayExpandButton && !expanded) {
|
||||
IconButton(
|
||||
modifier = Size15Modifier,
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderExpandButton(
|
||||
baseNote: Note,
|
||||
expanded: MutableState<Boolean>,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val showExpandButton by baseNote.live().relays.map {
|
||||
it.note.relays.size > 3
|
||||
}.observeAsState(baseNote.relays.size > 3)
|
||||
|
||||
if (showExpandButton && !expanded.value) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatRelayExpandButton(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
modifier = Size15Modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val iconUrl = remember(dirtyUrl) {
|
||||
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
|
||||
"https://$cleanUrl/favicon.ico"
|
||||
val iconUrl by remember(dirtyUrl) {
|
||||
derivedStateOf {
|
||||
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
|
||||
"https://$cleanUrl/favicon.ico"
|
||||
}
|
||||
}
|
||||
|
||||
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
|
||||
@@ -867,36 +889,48 @@ fun RenderRelay(dirtyUrl: String, accountViewModel: AccountViewModel, nav: (Stri
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = Size15dp)
|
||||
|
||||
val clickableModifier = remember(dirtyUrl) {
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(15.dp)
|
||||
.clickable(onClick = {
|
||||
loadRelayInfo(dirtyUrl, context, scope) {
|
||||
relayInfo = it
|
||||
.size(Size15dp)
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
interactionSource = interactionSource,
|
||||
indication = ripple,
|
||||
onClick = {
|
||||
loadRelayInfo(dirtyUrl, context, scope) {
|
||||
relayInfo = it
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
|
||||
val iconModifier = remember(dirtyUrl) {
|
||||
Modifier
|
||||
.size(13.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = clickableModifier
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = iconUrl,
|
||||
model = iconUrl,
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = iconModifier
|
||||
)
|
||||
RenderRelayIcon(iconUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayIcon(iconUrl: String) {
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier
|
||||
.size(Size13dp)
|
||||
.clip(shape = CircleShape)
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
}
|
||||
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = iconUrl,
|
||||
model = iconUrl,
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = RelayIconFilter,
|
||||
modifier = iconModifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -38,33 +38,49 @@ 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.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.service.firstFullChar
|
||||
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.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
|
||||
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val baseNote = remember { multiSetCard.note }
|
||||
@@ -89,7 +105,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
if (backgroundColor.value != newBackgroundColor) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,32 +134,39 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
Column(modifier = columnModifier) {
|
||||
Galeries(multiSetCard, backgroundColor, nav, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Galeries(multiSetCard, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
|
||||
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
Spacer(modifier = remember { Modifier.width(65.dp) })
|
||||
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
|
||||
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = remember { Modifier.padding(top = 5.dp) },
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = remember { Modifier.padding(top = 5.dp) },
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun Galeries(
|
||||
multiSetCard: MultiSetCard,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
|
||||
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
|
||||
@@ -152,17 +177,26 @@ private fun Galeries(
|
||||
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
|
||||
|
||||
if (hasZapEvents) {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
if (hasBoostEvents) {
|
||||
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
RenderBoostGallery(boostEvents, nav, accountViewModel)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
if (hasLikeEvents) {
|
||||
likeEvents.forEach {
|
||||
RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
likeEvents.forEach {
|
||||
RenderLikeGallery(it.key, it.value, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,39 +204,23 @@ private fun Galeries(
|
||||
fun RenderLikeGallery(
|
||||
reactionType: String,
|
||||
likeEvents: ImmutableList<Note>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val isNotEmpty = remember(likeEvents) {
|
||||
likeEvents.isNotEmpty()
|
||||
}
|
||||
|
||||
val shortReaction by remember {
|
||||
derivedStateOf {
|
||||
reactionType.take(2)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNotEmpty) {
|
||||
Row(remember { Modifier.fillMaxWidth() }) {
|
||||
if (likeEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
}
|
||||
modifier = NotificationIconModifier
|
||||
) {
|
||||
val modifier = remember {
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
}
|
||||
|
||||
when (shortReaction) {
|
||||
when (val shortReaction = reactionType.firstFullChar()) {
|
||||
"+" -> Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember { modifier.size(18.dp) },
|
||||
modifier = modifier.size(Size18dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
"-" -> Text(text = "\uD83D\uDC4E", modifier = modifier)
|
||||
@@ -210,7 +228,7 @@ fun RenderLikeGallery(
|
||||
}
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
|
||||
AuthorGallery(likeEvents, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,21 +250,17 @@ fun RenderZapGallery(
|
||||
@Composable
|
||||
private fun ZapIcon() {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
}
|
||||
modifier = WidthAuthorPictureModifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Size25dp)
|
||||
.align(Alignment.TopEnd)
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -254,21 +268,14 @@ private fun ZapIcon() {
|
||||
@Composable
|
||||
fun RenderBoostGallery(
|
||||
boostEvents: ImmutableList<Note>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(
|
||||
modifier = remember {
|
||||
Modifier.fillMaxWidth()
|
||||
}
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 4.dp)
|
||||
}
|
||||
modifier = NotificationIconModifierSmaller
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
@@ -282,7 +289,7 @@ fun RenderBoostGallery(
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel)
|
||||
AuthorGallery(boostEvents, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,15 +301,27 @@ fun AuthorGalleryZaps(
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.request, it.response, backgroundColor, nav, accountViewModel)
|
||||
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParseAuthorCommentAndAmount(
|
||||
zap: CombinedZap,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
|
||||
RenderState(state, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ZapAmountCommentNotification(
|
||||
val user: User?,
|
||||
@@ -311,12 +330,11 @@ data class ZapAmountCommentNotification(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
private fun ParseAuthorCommentAndAmount(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note?,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
onReady: @Composable (MutableState<ZapAmountCommentNotification>) -> Unit
|
||||
) {
|
||||
val content = remember {
|
||||
mutableStateOf(
|
||||
@@ -329,29 +347,44 @@ private fun AuthorPictureAndComment(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
|
||||
launch(Dispatchers.Default) {
|
||||
launch(Dispatchers.IO) {
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
val amount = (zapEvent?.event as? LnZapEvent)?.amount
|
||||
if (decryptedContent != null) {
|
||||
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content.value = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
|
||||
val newState = ZapAmountCommentNotification(newAuthor, decryptedContent.content.ifBlank { null }, showAmountAxis(amount))
|
||||
|
||||
launch(Dispatchers.Main) { content.value = newState }
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank() || amount != null) {
|
||||
content.value = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
|
||||
val newState = ZapAmountCommentNotification(zapRequest.author, zapRequest.event?.content()?.ifBlank { null }, showAmountAxis(amount))
|
||||
launch(Dispatchers.Main) { content.value = newState }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReady(content)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderState(
|
||||
content: MutableState<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable {
|
||||
nav("User/${content.value.user?.pubkeyHex}")
|
||||
content.value.user?.let {
|
||||
nav(routeFor(it))
|
||||
}
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AuthorPictureAndComment(
|
||||
DisplayAuthorCommentAndAmount(
|
||||
authorComment = content,
|
||||
backgroundColor = backgroundColor,
|
||||
nav = nav,
|
||||
@@ -361,91 +394,88 @@ private fun AuthorPictureAndComment(
|
||||
}
|
||||
|
||||
val amountBoxModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.size(Size35dp)
|
||||
.clip(shape = CircleShape)
|
||||
|
||||
val textBoxModifier = Modifier.padding(start = 5.dp).fillMaxWidth()
|
||||
|
||||
val simpleModifier = Modifier
|
||||
|
||||
val sizedModifier = Modifier.size(Size35dp)
|
||||
val textBoxModifier = Modifier
|
||||
.padding(start = 5.dp)
|
||||
.fillMaxWidth()
|
||||
|
||||
val bottomPadding1dp = Modifier.padding(bottom = 1.dp)
|
||||
|
||||
val commentTextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
private fun DisplayAuthorCommentAndAmount(
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Box(modifier = sizedModifier, contentAlignment = Alignment.BottomCenter) {
|
||||
DisplayPicture(authorComment, accountViewModel)
|
||||
DisplayAmount(authorComment)
|
||||
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
|
||||
CrossfadeToDisplayPicture(authorComment, accountViewModel)
|
||||
CrossfadeToDisplayAmount(authorComment)
|
||||
}
|
||||
|
||||
DisplayComment(authorComment, backgroundColor, nav, accountViewModel)
|
||||
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
|
||||
authorComment.value.user?.let {
|
||||
FastNoteAuthorPicture(
|
||||
author = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
pictureModifier = simpleModifier
|
||||
)
|
||||
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
|
||||
Crossfade(authorComment.value) {
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
|
||||
authorComment.value.amount?.let {
|
||||
Box(
|
||||
modifier = amountBoxModifier,
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
|
||||
Crossfade(authorComment.value, modifier = amountBoxModifier) {
|
||||
it.amount?.let {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
},
|
||||
modifier = amountBoxModifier,
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.secondaryVariant,
|
||||
fontSize = commentTextSize,
|
||||
modifier = bottomPadding1dp
|
||||
)
|
||||
val backgroundColor = MaterialTheme.colors.overPictureBackground
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(Size35dp)
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.secondaryVariant,
|
||||
fontSize = commentTextSize,
|
||||
modifier = bottomPadding1dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayComment(
|
||||
fun CrossfadeToDisplayComment(
|
||||
authorComment: MutableState<ZapAmountCommentNotification>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
authorComment.value.comment?.let {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = textBoxModifier,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
Crossfade(authorComment.value) {
|
||||
it.comment?.let {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = true,
|
||||
tags = remember { ImmutableListOfLists() },
|
||||
modifier = textBoxModifier,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,14 +483,13 @@ fun DisplayComment(
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: ImmutableList<Note>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach { note ->
|
||||
BoxedAuthor(note, backgroundColor, nav, accountViewModel)
|
||||
BoxedAuthor(note, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,84 +498,78 @@ fun AuthorGallery(
|
||||
@Composable
|
||||
private fun BoxedAuthor(
|
||||
note: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Box(sizedModifier) {
|
||||
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotePictureAndComment(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = remember(baseNote) {
|
||||
mutableStateOf(
|
||||
ZapAmountCommentNotification(
|
||||
user = baseNote.author,
|
||||
comment = null,
|
||||
amount = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val modifier = remember(baseNote) {
|
||||
Modifier.clickable {
|
||||
nav("User/${baseNote.author?.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
author: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
var profilePicture by remember {
|
||||
mutableStateOf(author.info?.picture)
|
||||
}
|
||||
|
||||
val authorPubKey = remember {
|
||||
author.pubkeyHex
|
||||
}
|
||||
|
||||
WatchUserMetadata(author) {
|
||||
if (it.picture != profilePicture) {
|
||||
profilePicture = it.picture
|
||||
}
|
||||
}
|
||||
|
||||
UserPicture(
|
||||
userHex = authorPubKey,
|
||||
userPicture = profilePicture,
|
||||
size = size,
|
||||
modifier = pictureModifier,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchUserMetadata(userBase: User, onMetadataChanges: (UserMetadata) -> Unit) {
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
LaunchedEffect(key1 = userState) {
|
||||
launch(Dispatchers.Default) {
|
||||
userState?.user?.info?.let {
|
||||
onMetadataChanges(it)
|
||||
Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) {
|
||||
WatchNoteAuthor(note) { targetAuthor ->
|
||||
Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author ->
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(author, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
|
||||
author: User?,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
if (author != null) {
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size35dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
author: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
WatchUserMetadata(author) { baseUserPicture ->
|
||||
Crossfade(targetState = baseUserPicture) { userPicture ->
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = userPicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = MaterialTheme.colors.profile35dpModifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
WatchFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
|
||||
Crossfade(targetState = isFollowing) {
|
||||
if (it) {
|
||||
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
|
||||
FollowingIcon(Size10dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchNoteAuthor(
|
||||
baseNote: Note,
|
||||
onContent: @Composable (User?) -> Unit
|
||||
) {
|
||||
val author by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.observeAsState(baseNote.author)
|
||||
|
||||
onContent(author)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchUserMetadata(
|
||||
author: User,
|
||||
onNewMetadata: @Composable (String?) -> Unit
|
||||
) {
|
||||
val userProfile by author.live().metadata.map {
|
||||
it.user.profilePicture()
|
||||
}.distinctUntilChanged().observeAsState(author.profilePicture())
|
||||
|
||||
onNewMetadata(userProfile)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -52,8 +54,8 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): MutableSta
|
||||
mutableStateOf(default)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = user.nip05) {
|
||||
if (nip05Verified.value == null) {
|
||||
if (nip05Verified.value == null) {
|
||||
LaunchedEffect(key1 = user.nip05) {
|
||||
launch(Dispatchers.IO) {
|
||||
user.nip05?.ifBlank { null }?.let { nip05 ->
|
||||
Nip05Verifier().verifyNip05(
|
||||
@@ -109,21 +111,16 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val isValidNIP05 by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.nip05()?.split("@")?.size == 2
|
||||
}
|
||||
}
|
||||
val nip05 by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.nip05()
|
||||
}
|
||||
}
|
||||
val nip05 by baseUser.live().metadata.map {
|
||||
it.user.nip05()
|
||||
}.observeAsState(baseUser.nip05())
|
||||
|
||||
if (isValidNIP05) {
|
||||
nip05?.let {
|
||||
DisplayNIP05Line(it, baseUser, columnModifier)
|
||||
Crossfade(targetState = nip05, modifier = columnModifier) {
|
||||
if (it != null) {
|
||||
val isValid = it.split("@").size == 2
|
||||
if (isValid) {
|
||||
DisplayNIP05Line(it, baseUser, columnModifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,9 +128,11 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
||||
@Composable
|
||||
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
Column(modifier = columnModifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
|
||||
DisplayNIP05(nip05, nip05Verified)
|
||||
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
|
||||
Crossfade(targetState = nip05Verified) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DisplayNIP05(nip05, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +149,7 @@ private fun DisplayNIP05(
|
||||
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(user),
|
||||
text = remember(nip05) { AnnotatedString(user) },
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -160,7 +159,7 @@ private fun DisplayNIP05(
|
||||
NIP05VerifiedSymbol(nip05Verified)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(domain),
|
||||
text = remember(nip05) { AnnotatedString(domain) },
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,11 +36,10 @@ import androidx.compose.material.icons.filled.PersonRemove
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
@@ -112,8 +111,8 @@ private fun VerticalDivider(color: Color) =
|
||||
|
||||
@Composable
|
||||
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
|
||||
var popupExpanded = remember { mutableStateOf(false) }
|
||||
val showPopup = remember(popupExpanded) { { popupExpanded.value = true } }
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
val showPopup = remember { { popupExpanded.value = true } }
|
||||
|
||||
content(showPopup)
|
||||
|
||||
@@ -122,192 +121,241 @@ fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, c
|
||||
|
||||
@Composable
|
||||
fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
|
||||
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
|
||||
var showDeleteAlertDialog by remember(note) { mutableStateOf(false) }
|
||||
var showBlockAlertDialog by remember(note) { mutableStateOf(false) }
|
||||
var showReportDialog by remember(note) { mutableStateOf(false) }
|
||||
val showSelectTextDialog = remember { mutableStateOf(false) }
|
||||
val showDeleteAlertDialog = remember { mutableStateOf(false) }
|
||||
val showBlockAlertDialog = remember { mutableStateOf(false) }
|
||||
val showReportDialog = remember { mutableStateOf(false) }
|
||||
|
||||
if (popupExpanded) {
|
||||
val context = LocalContext.current
|
||||
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
|
||||
val cardShape = RoundedCornerShape(5.dp)
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
RenderMainPopup(
|
||||
accountViewModel,
|
||||
note,
|
||||
onDismiss,
|
||||
showBlockAlertDialog,
|
||||
showDeleteAlertDialog,
|
||||
showReportDialog
|
||||
)
|
||||
}
|
||||
|
||||
val backgroundColor = if (MaterialTheme.colors.isLight) {
|
||||
MaterialTheme.colors.primary
|
||||
} else {
|
||||
MaterialTheme.colors.secondaryButtonBackground
|
||||
if (showSelectTextDialog.value) {
|
||||
accountViewModel.decrypt(note)?.let {
|
||||
SelectTextDialog(it) { showSelectTextDialog.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
val showToast = { stringResource: Int ->
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(stringResource),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
if (showDeleteAlertDialog.value) {
|
||||
DeleteAlertDialog(note, accountViewModel) {
|
||||
showDeleteAlertDialog.value = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
|
||||
if (showBlockAlertDialog.value) {
|
||||
BlockAlertDialog(note, accountViewModel) {
|
||||
showBlockAlertDialog.value = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
|
||||
Card(
|
||||
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
|
||||
shape = cardShape,
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
NoteQuickActionItem(
|
||||
icon = Icons.Default.ContentCopy,
|
||||
label = stringResource(R.string.quick_action_copy_text)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(
|
||||
accountViewModel.decrypt(note) ?: ""
|
||||
)
|
||||
if (showReportDialog.value) {
|
||||
ReportNoteDialog(note, accountViewModel) {
|
||||
showReportDialog.value = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderMainPopup(
|
||||
accountViewModel: AccountViewModel,
|
||||
note: Note,
|
||||
onDismiss: () -> Unit,
|
||||
showBlockAlertDialog: MutableState<Boolean>,
|
||||
showDeleteAlertDialog: MutableState<Boolean>,
|
||||
showReportDialog: MutableState<Boolean>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
|
||||
val cardShape = RoundedCornerShape(5.dp)
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val backgroundColor = if (MaterialTheme.colors.isLight) {
|
||||
MaterialTheme.colors.primary
|
||||
} else {
|
||||
MaterialTheme.colors.secondaryButtonBackground
|
||||
}
|
||||
|
||||
val showToast = { stringResource: Int ->
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(stringResource),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
|
||||
|
||||
Popup(onDismissRequest = onDismiss, alignment = Alignment.Center) {
|
||||
Card(
|
||||
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
|
||||
shape = cardShape,
|
||||
backgroundColor = backgroundColor
|
||||
) {
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
NoteQuickActionItem(
|
||||
icon = Icons.Default.ContentCopy,
|
||||
label = stringResource(R.string.quick_action_copy_text)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(
|
||||
accountViewModel.decrypt(note) ?: ""
|
||||
)
|
||||
showToast(R.string.copied_note_text_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
|
||||
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)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
|
||||
showToast(R.string.copied_note_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
|
||||
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
|
||||
if (accountViewModel.hideBlockAlertDialog) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
showBlockAlertDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(
|
||||
color = primaryLight,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.width(1.dp)
|
||||
)
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
if (isOwnNote) {
|
||||
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
|
||||
if (accountViewModel.hideDeleteRequestDialog) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
showDeleteAlertDialog = true
|
||||
}
|
||||
}
|
||||
} else if (isFollowingUser) {
|
||||
NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.unfollow(note.author!!)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.follow(note.author!!)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
icon = ImageVector.vectorResource(id = R.drawable.relays),
|
||||
label = stringResource(R.string.broadcast)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.broadcast(note)
|
||||
// showSelectTextDialog = true
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
externalLinkForNote(note)
|
||||
)
|
||||
putExtra(Intent.EXTRA_TITLE, context.getString(R.string.quick_action_share_browser_link))
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, context.getString(R.string.quick_action_share))
|
||||
ContextCompat.startActivity(context, shareIntent, null)
|
||||
)
|
||||
showToast(R.string.copied_note_text_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.AlternateEmail,
|
||||
stringResource(R.string.quick_action_copy_user_id)
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
|
||||
showToast(R.string.copied_note_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
|
||||
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
|
||||
showReportDialog = true
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.Block,
|
||||
stringResource(R.string.quick_action_block)
|
||||
) {
|
||||
if (accountViewModel.hideBlockAlertDialog) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
showBlockAlertDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider(
|
||||
color = primaryLight,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.width(1.dp)
|
||||
)
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
if (isOwnNote) {
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.Delete,
|
||||
stringResource(R.string.quick_action_delete)
|
||||
) {
|
||||
if (accountViewModel.hideDeleteRequestDialog) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
}
|
||||
} else {
|
||||
showDeleteAlertDialog.value = true
|
||||
}
|
||||
}
|
||||
} else if (isFollowingUser) {
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.PersonRemove,
|
||||
stringResource(R.string.quick_action_unfollow)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.unfollow(note.author!!)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.PersonAdd,
|
||||
stringResource(R.string.quick_action_follow)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.follow(note.author!!)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
icon = ImageVector.vectorResource(id = R.drawable.relays),
|
||||
label = stringResource(R.string.broadcast)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.broadcast(note)
|
||||
// showSelectTextDialog = true
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
icon = Icons.Default.Share,
|
||||
label = stringResource(R.string.quick_action_share)
|
||||
) {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
externalLinkForNote(note)
|
||||
)
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
context.getString(R.string.quick_action_share_browser_link)
|
||||
)
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(
|
||||
sendIntent,
|
||||
context.getString(R.string.quick_action_share)
|
||||
)
|
||||
ContextCompat.startActivity(context, shareIntent, null)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
if (!isOwnNote) {
|
||||
VerticalDivider(primaryLight)
|
||||
|
||||
NoteQuickActionItem(
|
||||
Icons.Default.Report,
|
||||
stringResource(R.string.quick_action_report)
|
||||
) {
|
||||
showReportDialog.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSelectTextDialog) {
|
||||
accountViewModel.decrypt(note)?.let {
|
||||
SelectTextDialog(it) { showSelectTextDialog = false }
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteAlertDialog) {
|
||||
DeleteAlertDialog(note, accountViewModel) {
|
||||
showDeleteAlertDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (showBlockAlertDialog) {
|
||||
BlockAlertDialog(note, accountViewModel) {
|
||||
showBlockAlertDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (showReportDialog) {
|
||||
ReportNoteDialog(note, accountViewModel) {
|
||||
showReportDialog = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -20,7 +20,6 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -32,6 +31,7 @@ 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.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -428,9 +428,12 @@ fun ZapVote(
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (!pollViewModel.canZap()) {
|
||||
val amountStr = remember(poolOption.zappedValue) {
|
||||
showAmount(poolOption.zappedValue)
|
||||
}
|
||||
Text(
|
||||
showAmount(poolOption.zappedValue),
|
||||
fontSize = 14.sp,
|
||||
text = amountStr,
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,10 @@ import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -37,9 +39,6 @@ fun RelayCompose(
|
||||
onAddRelay: () -> Unit,
|
||||
onRemoveRelay: () -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Column() {
|
||||
@@ -60,8 +59,14 @@ fun RelayCompose(
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
val lastTime by remember(relay.lastEvent) {
|
||||
derivedStateOf {
|
||||
timeAgo(relay.lastEvent, context = context)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
timeAgo(relay.lastEvent, context = context),
|
||||
text = lastTime,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
@@ -75,11 +80,7 @@ fun RelayCompose(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
if (account.activeRelays()?.none { it.url == relay.url } == true) {
|
||||
AddRelayButton { onAddRelay() }
|
||||
} else {
|
||||
RemoveRelayButton { onRemoveRelay() }
|
||||
}
|
||||
RelayOptions(accountViewModel, relay, onAddRelay, onRemoveRelay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +91,26 @@ fun RelayCompose(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayOptions(
|
||||
accountViewModel: AccountViewModel,
|
||||
relay: RelayInfo,
|
||||
onAddRelay: () -> Unit,
|
||||
onRemoveRelay: () -> Unit
|
||||
) {
|
||||
val userState by accountViewModel.userRelays.observeAsState()
|
||||
|
||||
val isNotUsingRelay = remember(userState) {
|
||||
accountViewModel.account.activeRelays()?.none { it.url == relay.url } == true
|
||||
}
|
||||
|
||||
if (isNotUsingRelay) {
|
||||
AddRelayButton(onAddRelay)
|
||||
} else {
|
||||
RemoveRelayButton(onRemoveRelay)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddRelayButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.firstFullChar
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -64,7 +65,7 @@ class UpdateReactionTypeViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
|
||||
fun addChoice() {
|
||||
val newValue = nextChoice.text.trim().take(2)
|
||||
val newValue = nextChoice.text.trim().firstFullChar()
|
||||
reactionSet = reactionSet + newValue
|
||||
|
||||
nextChoice = TextFieldValue("")
|
||||
|
||||
@@ -60,7 +60,6 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -78,6 +77,7 @@ 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 com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -415,7 +415,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
fontSize = 14.sp
|
||||
fontSize = Font14SP
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
|
||||
@Composable
|
||||
fun UserCompose(
|
||||
@@ -39,9 +39,9 @@ fun UserCompose(
|
||||
modifier = overallModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, nav, accountViewModel, 55.dp)
|
||||
UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(baseUser)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun UserCompose(
|
||||
AboutDisplay(baseUser)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
UserActionOptions(baseUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -293,6 +294,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "User Reactions Row")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
initializeSuspend()
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
@@ -20,11 +22,11 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
@@ -32,39 +34,36 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val author = remember(noteState) {
|
||||
noteState?.note?.author
|
||||
} ?: return
|
||||
val authorState by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.observeAsState(baseNote.author)
|
||||
|
||||
UsernameDisplay(author, weight)
|
||||
Crossfade(targetState = authorState, modifier = weight) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val bestUserName by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.bestUsername()
|
||||
}
|
||||
}
|
||||
val bestDisplayName by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.bestDisplayName()
|
||||
}
|
||||
}
|
||||
val npubDisplay by remember {
|
||||
derivedStateOf {
|
||||
baseUser.pubkeyDisplayHex()
|
||||
}
|
||||
}
|
||||
val tags by remember(userState) {
|
||||
derivedStateOf {
|
||||
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
|
||||
val userMetadata by baseUser.live().metadata.map {
|
||||
it.user.info
|
||||
}.observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = userMetadata, modifier = weight) {
|
||||
if (it != null) {
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, weight)
|
||||
}
|
||||
}
|
||||
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -103,16 +102,18 @@ private fun UserDisplay(
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
Row(modifier = modifier) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -122,22 +123,24 @@ private fun UserAndUsernameDisplay(
|
||||
bestUserName: String,
|
||||
modifier: Modifier
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
CreateTextWithEmoji(
|
||||
text = remember { "@$bestUserName" },
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
Row(modifier = modifier) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
CreateTextWithEmoji(
|
||||
text = remember { "@$bestUserName" },
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -106,7 +107,7 @@ private fun RenderZapNote(
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
|
||||
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
Column(
|
||||
modifier = remember {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
@@ -29,6 +30,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
init {
|
||||
Log.d("Init", "AccountStateViewModel")
|
||||
// pulls account from storage.
|
||||
|
||||
// Keeps it in the the UI thread to void blinking the login page.
|
||||
|
||||
@@ -101,7 +101,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val updatedCards = (oldNotesState.feed.value + newCards)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) {
|
||||
@@ -115,7 +115,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val cards = convertToCard(notes)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
updateFeed(cards)
|
||||
@@ -255,7 +255,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val updatedCards = (oldNotesState.feed.value + newCards)
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.id() }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.take(localFilter.limit())
|
||||
.toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(oldNotesState.feed.value, updatedCards)) {
|
||||
@@ -329,6 +329,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName}")
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -11,14 +12,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Composable
|
||||
fun ChatroomListFeedView(
|
||||
@@ -28,18 +30,19 @@ fun ChatroomListFeedView(
|
||||
markAsRead: MutableState<Boolean>
|
||||
) {
|
||||
RefresheableView(viewModel, true) {
|
||||
CorssFadeState(viewModel, accountViewModel, nav, markAsRead)
|
||||
CrossFadeState(viewModel, accountViewModel, nav, markAsRead)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CorssFadeState(
|
||||
private fun CrossFadeState(
|
||||
viewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
markAsRead: MutableState<Boolean>
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
@@ -68,6 +71,7 @@ private fun CorssFadeState(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
@@ -108,11 +112,14 @@ private fun FeedLoaded(
|
||||
key = { index, item -> if (index == 0) index else item.idHex }
|
||||
) { _, item ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
ChatroomCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
ChatroomCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Chat Header Complete: ${item.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -35,6 +36,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Composable
|
||||
fun RefresheableFeedView(
|
||||
@@ -175,7 +178,7 @@ private fun WatchScrollToTop(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
@@ -196,21 +199,24 @@ private fun FeedLoaded(
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
val defaultModifier = remember {
|
||||
Modifier
|
||||
.fillMaxWidth().animateItemPlacement()
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
val defaultModifier = remember {
|
||||
Modifier.fillMaxWidth().animateItemPlacement()
|
||||
}
|
||||
|
||||
Row(defaultModifier) {
|
||||
NoteCompose(
|
||||
item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = baseModifier,
|
||||
isBoostedNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(defaultModifier) {
|
||||
NoteCompose(
|
||||
item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = baseModifier,
|
||||
isBoostedNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
Log.d("Rendering Metrics", "Complete: ${item.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -80,11 +81,38 @@ class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFil
|
||||
}
|
||||
}
|
||||
|
||||
class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) {
|
||||
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrUserProfileNewThreadsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileNewThreadsFeedViewModel>): NostrUserProfileNewThreadsFeedViewModel {
|
||||
return NostrUserProfileNewThreadsFeedViewModel(user, account) as NostrUserProfileNewThreadsFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) {
|
||||
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrUserProfileConversationsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileConversationsFeedViewModel>): NostrUserProfileConversationsFeedViewModel {
|
||||
return NostrUserProfileConversationsFeedViewModel(user, account) as NostrUserProfileConversationsFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
||||
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
||||
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
|
||||
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
|
||||
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
|
||||
|
||||
class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) {
|
||||
class Factory(val user: User) : ViewModelProvider.Factory {
|
||||
override fun <NostrUserProfileReportFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileReportFeedViewModel>): NostrUserProfileReportFeedViewModel {
|
||||
return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) {
|
||||
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrUserProfileBookmarksFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileBookmarksFeedViewModel>): NostrUserProfileBookmarksFeedViewModel {
|
||||
return NostrUserProfileBookmarksFeedViewModel(user, account) as NostrUserProfileBookmarksFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
|
||||
@@ -257,6 +285,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName}")
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -84,6 +85,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName}")
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -90,7 +90,10 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedNote
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -120,17 +123,21 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
is FeedState.Loaded -> {
|
||||
refreshing = false
|
||||
LaunchedEffect(noteId) {
|
||||
// waits to load the thread to scroll to item.
|
||||
delay(100)
|
||||
val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull()
|
||||
var position = state.feed.value.indexOf(noteForPosition)
|
||||
launch(Dispatchers.IO) {
|
||||
// waits to load the thread to scroll to item.
|
||||
delay(100)
|
||||
val noteForPosition = state.feed.value.filter { it.idHex == noteId }.firstOrNull()
|
||||
var position = state.feed.value.indexOf(noteForPosition)
|
||||
|
||||
if (position >= 0) {
|
||||
if (position >= 1 && position < state.feed.value.size - 1) {
|
||||
position-- // show the replying note
|
||||
if (position >= 0) {
|
||||
if (position >= 1 && position < state.feed.value.size - 1) {
|
||||
position-- // show the replying note
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
listState.animateScrollToItem(position)
|
||||
}
|
||||
}
|
||||
|
||||
listState.animateScrollToItem(position)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +295,7 @@ fun NoteMaster(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
|
||||
|
||||
DisplayFollowingHashtagsInPost(noteEvent, accountViewModel, nav)
|
||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||
|
||||
Text(
|
||||
timeAgo(note.createdAt(), context = context),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -104,6 +105,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
Log.d("Init", "${this.javaClass.simpleName}")
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -34,6 +37,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
|
||||
|
||||
val userFollows: LiveData<UserState> = account.userProfile().live().follows.map { it }
|
||||
val userRelays: LiveData<UserState> = account.userProfile().live().relays.map { it }
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
return account.isWriteable()
|
||||
@@ -56,6 +60,11 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun isNoteHidden(note: Note): Boolean {
|
||||
val isSensitive = note.event?.isSensitive() ?: false
|
||||
return account.isHidden(note.author!!) || (isSensitive && account.showSensitiveContent == false)
|
||||
}
|
||||
|
||||
fun hasReactedTo(baseNote: Note, reaction: String): Boolean {
|
||||
return account.hasReacted(baseNote, reaction)
|
||||
}
|
||||
@@ -262,6 +271,29 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
return account.defaultZapType
|
||||
}
|
||||
|
||||
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
|
||||
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
|
||||
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
|
||||
|
||||
if (isFromLoggedIn || isFromLoggedInFollow) {
|
||||
// No need to process if from trusted people
|
||||
onReady(true, true, persistentSetOf())
|
||||
} else {
|
||||
val newCanPreview = !note.hasAnyReports()
|
||||
|
||||
val newIsAcceptable = account.isAcceptable(note)
|
||||
|
||||
if (newCanPreview && newIsAcceptable) {
|
||||
// No need to process reports if nothing is wrong
|
||||
onReady(true, true, persistentSetOf())
|
||||
} else {
|
||||
val newRelevantReports = account.getRelevantReports(note)
|
||||
|
||||
onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
|
||||
return AccountViewModel(account) as AccountViewModel
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -89,11 +88,12 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
|
||||
@@ -116,7 +116,7 @@ fun ChannelScreen(
|
||||
) {
|
||||
if (channelId == null) return
|
||||
|
||||
var channelBase by remember { mutableStateOf<Channel?>(null) }
|
||||
var channelBase by remember { mutableStateOf<Channel?>(LocalCache.getChannelIfExists(channelId)) }
|
||||
|
||||
LaunchedEffect(channelId) {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -364,14 +364,18 @@ fun EditFieldRow(
|
||||
onSendNewMessage()
|
||||
},
|
||||
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = Modifier.height(32.dp).padding(end = 10.dp)
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(end = 10.dp)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.placeholderText,
|
||||
modifier = Modifier.height(32.dp).padding(start = 2.dp)
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = 2.dp)
|
||||
) {
|
||||
val fileServer = if (isPrivate) {
|
||||
// TODO: Make private servers
|
||||
@@ -379,9 +383,11 @@ fun EditFieldRow(
|
||||
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME
|
||||
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
|
||||
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
|
||||
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
|
||||
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
|
||||
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
|
||||
}
|
||||
} else {
|
||||
@@ -478,6 +484,31 @@ fun MyTextField(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelHeader(
|
||||
channelNote: Note,
|
||||
showVideo: Boolean,
|
||||
showBottomDiviser: Boolean,
|
||||
modifier: Modifier = StdPadding,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val channelHex by remember {
|
||||
derivedStateOf {
|
||||
channelNote.channelHex()
|
||||
}
|
||||
}
|
||||
channelHex?.let {
|
||||
ChannelHeader(
|
||||
channelHex = it,
|
||||
showVideo = showVideo,
|
||||
showBottomDiviser = showBottomDiviser,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelHeader(
|
||||
channelHex: String,
|
||||
@@ -487,12 +518,13 @@ fun ChannelHeader(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var baseChannel by remember { mutableStateOf<Channel?>(LocalCache.channels[channelHex]) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var baseChannel by remember { mutableStateOf(LocalCache.channels[channelHex]) }
|
||||
|
||||
LaunchedEffect(key1 = channelHex) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
baseChannel = LocalCache.checkGetOrCreateChannel(channelHex)
|
||||
if (baseChannel == null) {
|
||||
LaunchedEffect(key1 = channelHex) {
|
||||
launch(Dispatchers.IO) {
|
||||
baseChannel = LocalCache.checkGetOrCreateChannel(channelHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,44 +549,65 @@ fun ChannelHeader(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = remember(channelState) { channelState?.channel } ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
Modifier.clickable {
|
||||
nav("Channel/${baseChannel.idHex}")
|
||||
}
|
||||
) {
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
val streamingUrl by remember(channelState) {
|
||||
derivedStateOf {
|
||||
channel.info?.streaming()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
nav("Channel/${baseChannel.idHex}")
|
||||
}
|
||||
}
|
||||
) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = remember(channelState) { channelState?.channel } ?: return
|
||||
|
||||
if (streamingUrl != null && showVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.heightIn(max = 300.dp)) {
|
||||
VideoView(
|
||||
videoUri = streamingUrl!!,
|
||||
description = null
|
||||
)
|
||||
val streamingUrl by remember(channelState) {
|
||||
derivedStateOf {
|
||||
val activity = channel as? LiveActivitiesChannel
|
||||
val description = activity?.info?.title()
|
||||
val url = activity?.info?.streaming()
|
||||
if (url != null) {
|
||||
ZoomableUrlVideo(url, description = description)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamingUrl != null && showVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
|
||||
ZoomableContentView(
|
||||
content = streamingUrl!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channel.idHex,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = context.getString(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(Size35dp)
|
||||
.height(Size35dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
channel.creator?.let {
|
||||
UserPicture(
|
||||
user = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
channel.profilePicture()?.let {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channel.idHex,
|
||||
model = it,
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.padding(start = 10.dp)
|
||||
.width(Size35dp)
|
||||
.height(Size35dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -564,7 +617,7 @@ fun ChannelHeader(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
channel.toBestDisplayName(),
|
||||
text = remember(channelState) { channel.toBestDisplayName() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -591,7 +644,7 @@ fun ChannelHeader(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(Size35dp)
|
||||
.padding(bottom = 3.dp),
|
||||
.padding(start = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (channel is PublicChatChannel) {
|
||||
@@ -650,42 +703,32 @@ private fun LiveChannelActionOptions(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isMe by remember(accountViewModel) {
|
||||
derivedStateOf {
|
||||
channel.creator == accountViewModel.account.userProfile()
|
||||
}
|
||||
}
|
||||
|
||||
val isLive by remember(channel) {
|
||||
derivedStateOf {
|
||||
channel.info?.status() == "live"
|
||||
}
|
||||
}
|
||||
|
||||
if (isMe) {
|
||||
// EditButton(accountViewModel, channel)
|
||||
} else {
|
||||
val note = remember(channel.idHex) {
|
||||
LocalCache.getNoteIfExists(channel.idHex)
|
||||
}
|
||||
val note = remember(channel.idHex) {
|
||||
LocalCache.getNoteIfExists(channel.idHex)
|
||||
}
|
||||
|
||||
note?.let {
|
||||
if (isLive) {
|
||||
LiveFlag()
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
|
||||
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
note?.let {
|
||||
if (isLive) {
|
||||
LiveFlag()
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LiveFlag() {
|
||||
fun LiveFlag() {
|
||||
Text(
|
||||
text = "LIVE",
|
||||
text = stringResource(id = R.string.live_stream_live_tag),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember {
|
||||
@@ -697,6 +740,51 @@ private fun LiveFlag() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EndedFlag() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_ended_tag),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.drawBehind { drawRect(Color.Black) }
|
||||
.padding(horizontal = 5.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OfflineFlag() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_offline_tag),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.drawBehind { drawRect(Color.Black) }
|
||||
.padding(horizontal = 5.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduledFlag() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_planned_tag),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.drawBehind { drawRect(Color.Black) }
|
||||
.padding(horizontal = 5.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoteCopyButton(
|
||||
note: Channel
|
||||
|
||||
@@ -71,8 +71,6 @@ fun ChatroomListScreen(
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.start()
|
||||
knownFeedViewModel.invalidateData(true)
|
||||
newFeedViewModel.invalidateData(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
|
||||
@@ -49,13 +49,15 @@ fun ChatroomScreen(
|
||||
) {
|
||||
if (userId == null) return
|
||||
|
||||
var userRoom by remember { mutableStateOf<User?>(null) }
|
||||
var userRoom by remember(userId) { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(userId) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
if (newUser != userRoom) {
|
||||
userRoom = newUser
|
||||
if (userRoom == null) {
|
||||
LaunchedEffect(userId) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
if (newUser != userRoom) {
|
||||
userRoom = newUser
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +198,7 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, nav: (Str
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UserPicture(
|
||||
ClickableUserPicture(
|
||||
baseUser = baseUser,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size35dp
|
||||
|
||||
@@ -34,8 +34,8 @@ import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.richTextDefaults
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -86,8 +86,8 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta
|
||||
Column(
|
||||
modifier = Modifier.padding(30.dp)
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
val myMarkDownStyle = RichTextDefaults.copy(
|
||||
stringStyle = RichTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -85,8 +86,10 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashtagHeader(tag: String, account: AccountViewModel) {
|
||||
Column() {
|
||||
fun HashtagHeader(tag: String, account: AccountViewModel, onClick: () -> Unit = { }) {
|
||||
Column(
|
||||
Modifier.clickable { onClick() }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(
|
||||
|
||||
@@ -36,8 +36,8 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.ui.dal.checkIfOnline
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||
@@ -76,6 +76,8 @@ fun HomeScreen(
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
liveActivitiesViewModel.invalidateData(true)
|
||||
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrHomeDataSource.invalidateFilters()
|
||||
@@ -187,18 +189,18 @@ private fun FeedLoaded(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
contentPadding = PaddingValues(),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
CheckIfOnline(item) {
|
||||
CheckIfLiveActivityIsOnline(item) {
|
||||
ChannelHeader(
|
||||
channelHex = item.idHex,
|
||||
channelHex = remember { item.idHex },
|
||||
showVideo = false,
|
||||
showBottomDiviser = true,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp),
|
||||
modifier = remember {
|
||||
Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
@@ -208,18 +210,20 @@ private fun FeedLoaded(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckIfOnline(note: Note, whenOnline: @Composable () -> Unit) {
|
||||
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
|
||||
val noteState by note.live().metadata.observeAsState()
|
||||
var online by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
launch(Dispatchers.IO) {
|
||||
online = checkIfOnline((note.event as? LiveActivitiesEvent)?.streaming())
|
||||
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
|
||||
}
|
||||
}
|
||||
|
||||
if (online) {
|
||||
whenOnline()
|
||||
Crossfade(targetState = online) {
|
||||
if (it) {
|
||||
whenOnline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user