Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b4cf444a | ||
|
|
f78ec5cc90 | ||
|
|
fd6a63a76c | ||
|
|
e0f186bb94 | ||
|
|
9fadab5bb9 | ||
|
|
ea768fb560 | ||
|
|
71cb18f360 | ||
|
|
3a1694ac9a | ||
|
|
91c47b6e87 | ||
|
|
fe4e03df39 | ||
|
|
d110b9fbbf | ||
|
|
dc8c177f20 | ||
|
|
1beacd240e | ||
|
|
083394b928 | ||
|
|
6de8d9d7e0 | ||
|
|
fb4457e767 | ||
|
|
d4c1907349 | ||
|
|
c92926f3ab | ||
|
|
8f4f8f7953 | ||
|
|
5a3ea1c258 | ||
|
|
a567ec70ad | ||
|
|
e402081777 | ||
|
|
ed9c27341f | ||
|
|
56d9c9a50f | ||
|
|
d2a22f4ca0 | ||
|
|
09582fd0b1 | ||
|
|
1d5dfbfd29 | ||
|
|
044d47adad | ||
|
|
cd9465c0e7 | ||
|
|
d179c93c0b | ||
|
|
3c84eae647 | ||
|
|
53a146d0e5 | ||
|
|
57db7f0272 | ||
|
|
b820b6564f | ||
|
|
3908c42c7f | ||
|
|
1793fe40bf | ||
|
|
0857695332 | ||
|
|
a7b3bac8f8 | ||
|
|
e6f122d8b7 | ||
|
|
b2cdc2ddef | ||
|
|
94057059bb | ||
|
|
927ef17896 | ||
|
|
33f915879b | ||
|
|
98bca48359 | ||
|
|
171f763908 | ||
|
|
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 |
13
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,8 +16,6 @@ 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)
|
||||
@@ -191,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
|
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 223
|
||||
versionName "0.62.2"
|
||||
versionCode 237
|
||||
versionName "0.65.1"
|
||||
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 22 KiB |
@@ -55,6 +55,7 @@ private object PrefKeys {
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -216,6 +217,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
@@ -256,6 +258,7 @@ object LocalPreferences {
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
@@ -349,6 +352,7 @@ object LocalPreferences {
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
|
||||
@@ -14,7 +14,7 @@ import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
@@ -67,6 +67,7 @@ object ServiceManager {
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
@@ -88,8 +89,8 @@ object ServiceManager {
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrDiscoveryDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
|
||||
@@ -62,6 +62,7 @@ class Account(
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -899,6 +900,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
defaultDiscoveryFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -1134,7 +1141,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()
|
||||
}
|
||||
|
||||
@@ -18,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()
|
||||
|
||||
@@ -45,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
|
||||
}
|
||||
@@ -63,6 +56,16 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
|
||||
}
|
||||
|
||||
override fun profilePicture(): String? {
|
||||
if (info.picture.isNullOrBlank()) return 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,26 +1,31 @@
|
||||
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))
|
||||
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> 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))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.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,
|
||||
|
||||
@@ -3,9 +3,9 @@ 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
|
||||
@@ -13,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
|
||||
@@ -36,25 +35,24 @@ object LocalCache {
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreateUser(key)
|
||||
}
|
||||
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? {
|
||||
@@ -71,7 +69,7 @@ object LocalCache {
|
||||
if (ATag.isATag(key)) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
if (isValidHexNpub(key)) {
|
||||
if (isValidHex(key)) {
|
||||
val note = getOrCreateNote(key)
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
@@ -84,21 +82,19 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
if (isValidHex(key)) {
|
||||
return getOrCreateChannel(key) {
|
||||
PublicChatChannel(key)
|
||||
}
|
||||
@@ -112,26 +108,19 @@ object LocalCache {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isValidHexNpub(key: String): Boolean {
|
||||
private fun isValidHex(key: String): Boolean {
|
||||
if (key.isBlank()) return false
|
||||
if (key.contains(":")) return false
|
||||
|
||||
return try {
|
||||
Hex.decode(key).toNpub()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,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)
|
||||
@@ -317,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)
|
||||
@@ -547,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)
|
||||
|
||||
@@ -812,7 +798,7 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
|
||||
Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
@@ -1289,7 +1275,7 @@ class LocalCacheLiveData {
|
||||
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 ->
|
||||
|
||||
@@ -77,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
|
||||
@@ -270,6 +279,20 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun publicZapAuthors(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthors(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author?.pubkeyHex }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): String? {
|
||||
return reactions.filter {
|
||||
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
|
||||
@@ -493,7 +516,7 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate() {
|
||||
|
||||
@@ -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
|
||||
@@ -187,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()
|
||||
}
|
||||
}
|
||||
@@ -237,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 {
|
||||
@@ -373,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 {
|
||||
@@ -401,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
|
||||
@@ -414,11 +438,28 @@ 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)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import java.util.regex.Pattern
|
||||
|
||||
@Immutable
|
||||
data class RichTextViewerState(
|
||||
val urlSet: ImmutableSet<String>,
|
||||
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
|
||||
val imageList: ImmutableList<ZoomableUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ImmutableList<Segment>>
|
||||
)
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<String, RichTextViewerState>(200)
|
||||
|
||||
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
|
||||
return if (richTextCache[content] != null) {
|
||||
richTextCache[content]
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags)
|
||||
richTextCache.put(content, newUrls)
|
||||
newUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
class RichTextParser() {
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>
|
||||
): RichTextViewerState {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
|
||||
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
|
||||
// removes e-mails
|
||||
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
|
||||
null
|
||||
} else if (isNumber(it.originalUrl)) {
|
||||
null
|
||||
} else {
|
||||
it.originalUrl
|
||||
}
|
||||
}
|
||||
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableUrlVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
val emojiMap =
|
||||
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
|
||||
|
||||
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
|
||||
|
||||
return RichTextViewerState(
|
||||
urlSet.toImmutableSet(),
|
||||
imagesForPager.toImmutableMap(),
|
||||
imageList.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments
|
||||
)
|
||||
}
|
||||
|
||||
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
|
||||
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
var segments = persistentListOf<Segment>()
|
||||
var isDirty = false
|
||||
|
||||
val wordList = paragraph.split(' ')
|
||||
wordList.forEach { word ->
|
||||
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
|
||||
if (wordSegment !is RegularTextSegment) {
|
||||
isDirty = true
|
||||
}
|
||||
segments = segments.add(wordSegment)
|
||||
}
|
||||
|
||||
val newSegments = if (isDirty) {
|
||||
if (isArabic(paragraph)) {
|
||||
segments.asReversed().toImmutableList()
|
||||
} else {
|
||||
segments
|
||||
}
|
||||
} else {
|
||||
persistentListOf<Segment>(RegularTextSegment(paragraph))
|
||||
}
|
||||
|
||||
paragraphSegments = paragraphSegments.add(newSegments)
|
||||
}
|
||||
|
||||
return paragraphSegments
|
||||
}
|
||||
|
||||
fun isNumber(word: String): Boolean {
|
||||
return numberPattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
fun isDate(word: String): Boolean {
|
||||
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
|
||||
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
|
||||
val phoneMatcher = Patterns.PHONE.matcher(word)
|
||||
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
|
||||
|
||||
return if (word.isEmpty()) {
|
||||
RegularTextSegment(word)
|
||||
} else if (images.contains(word)) {
|
||||
ImageSegment(word)
|
||||
} else if (urls.contains(word)) {
|
||||
LinkSegment(word)
|
||||
} else if (emojis.any { word.contains(it.key) }) {
|
||||
EmojiSegment(word)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
InvoiceSegment(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
WithdrawSegment(word)
|
||||
} else if (word.startsWith("cashuA", true)) {
|
||||
CashuSegment(word)
|
||||
} else if (emailMatcher.matches()) {
|
||||
EmailSegment(word)
|
||||
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
|
||||
PhoneSegment(word)
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
BechSegment(word)
|
||||
} else if (word.startsWith("#")) {
|
||||
parseHash(word, tags)
|
||||
} else if (schemelessMatcher.find()) {
|
||||
val url = schemelessMatcher.group(1) // url
|
||||
val additionalChars = schemelessMatcher.group(4) // additional chars
|
||||
if (url != null) {
|
||||
SchemelessUrlSegment(word, url, additionalChars)
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
} else {
|
||||
RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
|
||||
// First #[n]
|
||||
|
||||
val matcher = tagIndex.matcher(word)
|
||||
try {
|
||||
if (matcher.find()) {
|
||||
val index = matcher.group(1)?.toInt()
|
||||
val suffix = matcher.group(2)
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
return HashIndexUserSegment(word, tag[1], suffix)
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
return HashIndexEventSegment(word, tag[1], suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
}
|
||||
|
||||
// Second #Amethyst
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
try {
|
||||
if (hashtagMatcher.find()) {
|
||||
val hashtag = hashtagMatcher.group(1)
|
||||
if (hashtag != null) {
|
||||
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
}
|
||||
|
||||
return RegularTextSegment(word)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Segment(val segmentText: String)
|
||||
|
||||
@Immutable
|
||||
class ImageSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class LinkSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmojiSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class InvoiceSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class WithdrawSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class CashuSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class EmailSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class PhoneSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BechSegment(segment: String) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
|
||||
|
||||
@Immutable
|
||||
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
@@ -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 = "Redeem 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
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ class Nip05Verifier() {
|
||||
if (parts.size == 2) {
|
||||
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
|
||||
}
|
||||
if (parts.size == 1) {
|
||||
return "https://${parts[0]}/.well-known/nostr.json?name=_"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -31,7 +34,7 @@ class Nip05Verifier() {
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// checkNotInMainThread()
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
@@ -49,6 +52,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 +76,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.
|
||||
@@ -88,7 +95,12 @@ class Nip05Verifier() {
|
||||
null
|
||||
}
|
||||
|
||||
val user = nip05.split("@")[0].lowercase()
|
||||
val parts = nip05.split("@")
|
||||
val user = if (parts.size == 2) {
|
||||
parts[0].lowercase()
|
||||
} else {
|
||||
"_"
|
||||
}
|
||||
|
||||
val hexKey = nip05url?.get("names")?.get(user)?.asText()
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 1000
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind, LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 1000
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
discoveryFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -51,13 +51,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
|
||||
val followKeys = follows.map {
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
|
||||
@@ -122,6 +122,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
checkNotInMainThread()
|
||||
|
||||
eventsToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
@@ -159,16 +161,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
if (!addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
fun addAddress(addressableNote: Note) {
|
||||
if (!addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.plus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
if (addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
fun removeAddress(addressableNote: Note) {
|
||||
if (addressesToWatch.contains(addressableNote)) {
|
||||
addressesToWatch = addressesToWatch.minus(addressableNote)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -25,15 +25,27 @@ class LiveActivitiesEvent(
|
||||
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
|
||||
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
|
||||
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
|
||||
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
|
||||
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
|
||||
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
|
||||
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
|
||||
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
|
||||
fun checkStatus(eventStatus: String?): String? {
|
||||
return if (eventStatus == STATUS_LIVE && createdAt < Date().time / 1000 - (60 * 60 * 8)) { // 2 hours {
|
||||
STATUS_ENDED
|
||||
} else {
|
||||
eventStatus
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30311
|
||||
|
||||
const val STATUS_LIVE = "live"
|
||||
const val STATUS_PLANNED = "planned"
|
||||
const val STATUS_ENDED = "ended"
|
||||
|
||||
fun create(
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ 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
|
||||
@@ -389,7 +390,7 @@ fun UserComposeForChat(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
ClickableUserPicture(baseUser, 55.dp, accountViewModel)
|
||||
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
@@ -88,8 +89,7 @@ import kotlinx.coroutines.withContext
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
val account = remember(accountViewModel) { accountViewModel.account }
|
||||
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ import com.vitorpamplona.amethyst.service.model.AddressableEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -29,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
|
||||
@@ -246,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,31 +292,44 @@ fun loadRelayInfo(
|
||||
scope: CoroutineScope,
|
||||
onInfo: (RelayInformation) -> Unit
|
||||
) {
|
||||
val url = if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
try {
|
||||
val url = if (dirtyUrl.contains("://")) {
|
||||
dirtyUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
} else {
|
||||
"https://$dirtyUrl"
|
||||
}
|
||||
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
val request: Request = Request
|
||||
.Builder()
|
||||
.header("Accept", "application/nostr+json")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
try {
|
||||
if (it.isSuccessful) {
|
||||
onInfo(RelayInformation.fromJson(body))
|
||||
} else {
|
||||
HttpClient.getHttpClient()
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
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 $dirtyUrl in not parseable: $body", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
@@ -320,31 +339,31 @@ fun loadRelayInfo(
|
||||
).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, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable $dirtyUrl", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
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, dirtyUrl),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("RelayInfoFail", "Invalid URL $dirtyUrl", e)
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@@ -15,7 +16,7 @@ fun ClickableEmail(email: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$email "),
|
||||
text = remember { AnnotatedString(email) },
|
||||
onClick = { runCatching { context.sendMail(email) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@@ -15,7 +16,7 @@ fun ClickablePhone(phone: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$phone "),
|
||||
text = remember { AnnotatedString(phone) },
|
||||
onClick = { runCatching { context.dial(phone) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -78,7 +79,7 @@ fun ClickableRoute(
|
||||
else -> {
|
||||
Text(
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
"@${nip19.hex}${nip19.additionalChars}"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -111,7 +112,16 @@ private fun DisplayEvent(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadNote(nip19.hex) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
suffix = nip19.additionalChars,
|
||||
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +131,16 @@ private fun DisplayNote(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LoadNote(nip19.hex) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
|
||||
suffix = nip19.additionalChars,
|
||||
route = remember(nip19) { "Event/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +156,7 @@ private fun DisplayNoteLink(
|
||||
|
||||
val channelHex = remember(noteState) { note.channelHex() }
|
||||
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
val addedCharts = remember { "${nip19.additionalChars}" }
|
||||
|
||||
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
|
||||
CreateClickableText(
|
||||
@@ -199,7 +218,7 @@ private fun DisplayAddress(
|
||||
|
||||
val route = remember(noteState) { "Note/${nip19.hex}" }
|
||||
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
val addedCharts = remember { "${nip19.additionalChars}" }
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = displayName,
|
||||
@@ -212,7 +231,7 @@ private fun DisplayAddress(
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
"@${nip19.hex}${nip19.additionalChars}"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -240,7 +259,7 @@ private fun DisplayUser(
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
"@${nip19.hex}${nip19.additionalChars}"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -268,7 +287,7 @@ private fun RenderUserAsClickableText(
|
||||
}
|
||||
|
||||
val addedCharts = remember(nip19) {
|
||||
"${nip19.additionalChars} "
|
||||
"${nip19.additionalChars}"
|
||||
}
|
||||
|
||||
userDisplayName?.let {
|
||||
@@ -286,7 +305,7 @@ private fun RenderUserAsClickableText(
|
||||
@Composable
|
||||
fun CreateClickableText(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
suffix: String?,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
@@ -310,8 +329,10 @@ fun CreateClickableText(
|
||||
withStyle(clickablePartStyle) {
|
||||
append(clickablePart)
|
||||
}
|
||||
withStyle(nonClickablePartStyle) {
|
||||
append(suffix)
|
||||
if (!suffix.isNullOrBlank()) {
|
||||
withStyle(nonClickablePartStyle) {
|
||||
append(suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -488,7 +509,7 @@ data class DoubleEmojiList(
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
suffix: String?,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
@@ -507,7 +528,7 @@ fun CreateClickableTextWithEmoji(
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
||||
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
|
||||
val newEmojiList2 = suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList<Renderable>()
|
||||
|
||||
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
||||
emojiLists = DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -19,13 +20,12 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
@@ -80,16 +80,7 @@ fun ExpandableRichTextViewer(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
drawRect(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
backgroundColor.value.copy(alpha = 0f),
|
||||
backgroundColor.value
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.background(getGradient(backgroundColor))
|
||||
) {
|
||||
ShowMoreButton() {
|
||||
showFullText = !showFullText
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +50,7 @@ fun RobohashAsyncImage(
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
robot: String,
|
||||
model: String,
|
||||
model: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
@@ -91,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ fun UrlPreview(url: String, urlText: String) {
|
||||
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,6 +1,7 @@
|
||||
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
|
||||
@@ -10,7 +11,6 @@ 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
|
||||
@@ -24,7 +24,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.Icon
|
||||
@@ -36,8 +36,10 @@ 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.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -45,11 +47,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.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
|
||||
@@ -58,12 +60,12 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
@@ -72,7 +74,7 @@ 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.hashVerified
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -206,90 +208,57 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
// store the dialog open or close state
|
||||
var imageLoadingState by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
}
|
||||
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
}
|
||||
val contentScale = remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
|
||||
val contentScale = remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
val verifierModifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
val painterState = remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
AsyncImage(
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onError = {
|
||||
if (imageLoadingState != false) {
|
||||
imageLoadingState = false
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
if (imageLoadingState != true) {
|
||||
imageLoadingState = true
|
||||
}
|
||||
onState = {
|
||||
painterState.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (imageLoadingState == true) {
|
||||
if (content.isVerified != null) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
} else if (imageLoadingState == false || content.localFile == null || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
|
||||
}
|
||||
} else {
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageLoadingStatus by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
@@ -305,35 +274,37 @@ private fun UrlImageView(
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
|
||||
val verifierModifier = Modifier.align(Alignment.TopEnd)
|
||||
|
||||
val painterState = remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onError = {
|
||||
if (imageLoadingStatus != false) {
|
||||
imageLoadingStatus = false
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
if (verifiedHash == null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
if (imageLoadingStatus != true) {
|
||||
imageLoadingStatus = true
|
||||
}
|
||||
onState = {
|
||||
painterState.value = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageLoadingStatus == true) {
|
||||
verifiedHash?.let {
|
||||
HashVerificationSymbol(it, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
} else if (imageLoadingStatus == false) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
} else {
|
||||
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun AddedImageFeatures(
|
||||
painter: MutableState<AsyncImagePainter.State?>,
|
||||
content: ZoomableLocalImage,
|
||||
contentScale: ContentScale,
|
||||
myModifier: Modifier,
|
||||
verifiedModifier: Modifier
|
||||
) {
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
@@ -342,6 +313,70 @@ private fun UrlImageView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
BlankNote()
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
if (content.isVerified != null) {
|
||||
HashVerificationSymbol(content.isVerified, verifiedModifier)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun AddedImageFeatures(
|
||||
painter: MutableState<AsyncImagePainter.State?>,
|
||||
content: ZoomableUrlImage,
|
||||
contentScale: ContentScale,
|
||||
myModifier: Modifier,
|
||||
verifiedModifier: Modifier
|
||||
) {
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
when (painter.value) {
|
||||
null, is AsyncImagePainter.State.Loading -> {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
|
||||
is AsyncImagePainter.State.Success -> {
|
||||
if (content.hash != null) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(key1 = content.url) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newVerifiedHash = verifyHash(content, context)
|
||||
if (newVerifiedHash != verifiedHash) {
|
||||
verifiedHash = newVerifiedHash
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verifiedHash?.let {
|
||||
HashVerificationSymbol(it, verifiedModifier)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +412,7 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
if (content is ZoomableUrlContent) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
ClickableUrl(urlText = remember { "${content.url} " }, url = content.url)
|
||||
} else {
|
||||
Text("Loading content... ")
|
||||
}
|
||||
@@ -396,8 +431,8 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
@@ -439,13 +474,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)
|
||||
@@ -454,6 +510,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)
|
||||
@@ -470,18 +538,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,15 +596,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
|
||||
.height(40.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (verifiedHash == true) {
|
||||
if (verifiedHash) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
@@ -563,11 +611,11 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.hashVerified,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else if (verifiedHash == false) {
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
|
||||
class DiscoverFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
|
||||
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
|
||||
|
||||
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
|
||||
val activities = collection
|
||||
.asSequence()
|
||||
.filter { it.event is LiveActivitiesEvent }
|
||||
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter { (it.createdAt() ?: 0) <= now }
|
||||
.toSet()
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(
|
||||
compareBy(
|
||||
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
|
||||
{ it.createdAt() },
|
||||
{ it.idHex }
|
||||
)
|
||||
).reversed()
|
||||
}
|
||||
|
||||
fun convertStatusToOrder(status: String?): Int {
|
||||
return when (status) {
|
||||
STATUS_LIVE -> 2
|
||||
STATUS_PLANNED -> 1
|
||||
STATUS_ENDED -> 0
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +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.*
|
||||
|
||||
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val followChannels = account.followingChannels
|
||||
val followUsers = account.followingKeySet()
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
val channel = it.channelHex()
|
||||
// does not show events already in the public chat list
|
||||
(channel == null || channel !in followChannels) &&
|
||||
// does not show people the user already follows
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
||||
it.createdAt()!! <= now
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -23,6 +24,8 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
@@ -32,7 +35,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
(isGlobal || 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) &&
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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.GLOBAL_FOLLOWS
|
||||
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 com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import java.util.Date
|
||||
|
||||
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -32,17 +31,19 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
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())) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())) &&
|
||||
(isGlobal || 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 +56,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
@@ -29,10 +30,12 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val now = Date().time / 1000
|
||||
val oneMinuteInTheFuture = Date().time / 1000 + (1 * 60) // one minute in the future.
|
||||
val oneHr = 60 * 60
|
||||
|
||||
return collection
|
||||
@@ -40,10 +43,10 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
(noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
|
||||
(isGlobal || 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) < now) &&
|
||||
((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 }))
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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
|
||||
@@ -246,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,
|
||||
|
||||
@@ -48,7 +48,7 @@ val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
Route.Message,
|
||||
Route.Video,
|
||||
Route.Search,
|
||||
Route.Discover,
|
||||
Route.Notification
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
@@ -42,8 +43,8 @@ fun AppNavigation(
|
||||
liveActivitiesViewModel: NostrHomeFeedLiveActivitiesViewModel,
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
|
||||
notifFeedViewModel: NotificationViewModel,
|
||||
userReactionsStatsModel: UserReactionsViewModel,
|
||||
|
||||
@@ -108,10 +109,19 @@ fun AppNavigation(
|
||||
})
|
||||
}
|
||||
|
||||
Route.Discover.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
DiscoverScreen(
|
||||
discoveryFeedViewModel = discoveryFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
SearchScreen(
|
||||
searchFeedViewModel = searchFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
|
||||
@@ -48,7 +48,6 @@ 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
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
@@ -61,7 +60,6 @@ import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
@@ -74,9 +72,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
@@ -122,6 +118,7 @@ private fun RenderTopRouteBar(
|
||||
// Route.Profile.route -> TopBarWithBackButton(nav)
|
||||
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel, nav)
|
||||
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel, nav)
|
||||
Route.Discover.base -> DiscoveryTopBar(followLists, scaffoldState, accountViewModel, nav)
|
||||
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel, nav)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel, nav)
|
||||
}
|
||||
@@ -158,7 +155,7 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
false
|
||||
true
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
@@ -182,6 +179,23 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericTopBar(scaffoldState, accountViewModel, nav) { accountViewModel ->
|
||||
val list by accountViewModel.accountLiveData.map {
|
||||
it.account.defaultDiscoveryFollowList
|
||||
}.observeAsState(GLOBAL_FOLLOWS)
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericTopBar(scaffoldState, accountViewModel, nav) {
|
||||
@@ -194,14 +208,6 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel,
|
||||
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, nav: (String) -> Unit, content: @Composable (AccountViewModel) -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToEditRelays) {
|
||||
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.height(50.dp)) {
|
||||
TopAppBar(
|
||||
elevation = 0.dp,
|
||||
@@ -222,10 +228,6 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
) {
|
||||
content(accountViewModel)
|
||||
}
|
||||
|
||||
RelayStatus(
|
||||
{ wantsToEditRelays = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -237,7 +239,9 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
RelayIcon { wantsToEditRelays = true }
|
||||
SearchIcon() {
|
||||
nav(Route.Search.route)
|
||||
}
|
||||
}
|
||||
)
|
||||
Divider(thickness = 0.25.dp)
|
||||
@@ -245,80 +249,14 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RelayStatus(relayViewModel, onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
relayViewModel: RelayPoolViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
|
||||
}
|
||||
}
|
||||
|
||||
val isConnected by remember(connectedRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
(connectedRelaysLiveData.value ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
RenderRelayStatus(connectedRelaysText, isConnected, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayStatus(
|
||||
connectedRelaysText: String,
|
||||
isConnected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Text(
|
||||
text = connectedRelaysText,
|
||||
color = if (isConnected) {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
} else {
|
||||
Color.Red
|
||||
},
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
modifier = Modifier.clickable(
|
||||
onClick = onClick
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayIcon(onClick: () -> Unit) {
|
||||
private fun SearchIcon(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
@@ -534,7 +472,6 @@ fun AmethystIcon() {
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.TextButton
|
||||
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
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -52,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
@@ -60,13 +62,17 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -301,6 +307,12 @@ fun ListContent(
|
||||
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
@@ -324,6 +336,16 @@ fun ListContent(
|
||||
route = Route.Bookmarks.route
|
||||
)
|
||||
|
||||
IconRowRelays(
|
||||
relayViewModel = relayViewModel,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
wantsToEditRelays = true
|
||||
}
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.security_filters),
|
||||
icon = Route.BlockedUsers.icon,
|
||||
@@ -431,6 +453,10 @@ fun ListContent(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToEditRelays) {
|
||||
NewRelayListView({ wantsToEditRelays = false }, accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableTor(
|
||||
@@ -446,6 +472,44 @@ private fun enableTor(
|
||||
ServiceManager.start(context)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
relayViewModel: RelayPoolViewModel
|
||||
) {
|
||||
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
|
||||
}
|
||||
}
|
||||
|
||||
val isConnected by remember(connectedRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
(connectedRelaysLiveData.value ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
RenderRelayStatus(connectedRelaysText, isConnected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayStatus(
|
||||
connectedRelaysText: String,
|
||||
isConnected: Boolean
|
||||
) {
|
||||
Text(
|
||||
text = connectedRelaysText,
|
||||
color = if (isConnected) {
|
||||
MaterialTheme.colors.placeholderText
|
||||
} else {
|
||||
Color.Red
|
||||
},
|
||||
style = MaterialTheme.typography.subtitle1
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationRow(
|
||||
title: String,
|
||||
@@ -496,6 +560,39 @@ fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongCl
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconRowRelays(relayViewModel: RelayPoolViewModel, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
text = stringResource(id = R.string.relays),
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(Size16dp))
|
||||
|
||||
RelayStatus(relayViewModel = relayViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomContent(user: User, scaffoldState: ScaffoldState, nav: (String) -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
@@ -42,9 +42,14 @@ sealed class Route(
|
||||
hasNewItems = { accountViewModel, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, newNotes) }
|
||||
)
|
||||
|
||||
object Global : Route(
|
||||
route = "Global",
|
||||
icon = R.drawable.ic_globe
|
||||
)
|
||||
|
||||
object Search : Route(
|
||||
route = "Search",
|
||||
icon = R.drawable.ic_globe
|
||||
icon = R.drawable.ic_search
|
||||
)
|
||||
|
||||
object Video : Route(
|
||||
@@ -52,6 +57,11 @@ sealed class Route(
|
||||
icon = R.drawable.ic_video
|
||||
)
|
||||
|
||||
object Discover : Route(
|
||||
route = "Discover",
|
||||
icon = R.drawable.ic_sensors
|
||||
)
|
||||
|
||||
object Notification : Route(
|
||||
route = "Notification",
|
||||
icon = R.drawable.ic_notifications,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -26,7 +27,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -79,9 +79,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.drawBehind {
|
||||
drawRect(backgroundColor.value)
|
||||
}
|
||||
.background(backgroundColor.value)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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
|
||||
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.Companion.BottomStart
|
||||
import androidx.compose.ui.Alignment.Companion.TopEnd
|
||||
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.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChannelCardCompose(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.distinctUntilChanged().observeAsState(baseNote.event == null)
|
||||
|
||||
Crossfade(targetState = isBlank) {
|
||||
if (it) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
BlankNote(
|
||||
remember {
|
||||
modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = showPopup
|
||||
)
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CheckHiddenChannelCardCompose(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckHiddenChannelCardCompose(
|
||||
note: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isHidden by accountViewModel.accountLiveData.map {
|
||||
accountViewModel.isNoteHidden(note)
|
||||
}.distinctUntilChanged().observeAsState(accountViewModel.isNoteHidden(note))
|
||||
|
||||
Crossfade(targetState = isHidden) {
|
||||
if (!it) {
|
||||
LoadedChannelCardCompose(
|
||||
note,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadedChannelCardCompose(
|
||||
note: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
NoteComposeReportState(
|
||||
isAcceptable = true,
|
||||
canPreview = true,
|
||||
relevantReports = persistentSetOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
|
||||
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
|
||||
val newState = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
RenderChannelCardReportState(
|
||||
it,
|
||||
note,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderChannelCardReportState(
|
||||
state: NoteComposeReportState,
|
||||
note: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
accountViewModel,
|
||||
modifier,
|
||||
false,
|
||||
nav,
|
||||
onClick = { showReportedNote = true }
|
||||
)
|
||||
} else {
|
||||
NormalChannelCard(
|
||||
note,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NormalChannelCard(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderChannelCard(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
showPopup,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckNewAndRenderChannelCard(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
showPopup: () -> Unit,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val newItemColor = MaterialTheme.colors.newItemBackgroundColor
|
||||
val defaultBackgroundColor = MaterialTheme.colors.background
|
||||
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) {
|
||||
launch(Dispatchers.IO) {
|
||||
routeForLastRead?.let {
|
||||
val lastTime = accountViewModel.account.loadLastRead(it)
|
||||
|
||||
val createdAt = baseNote.createdAt()
|
||||
if (createdAt != null) {
|
||||
accountViewModel.account.markAsRead(it, createdAt)
|
||||
|
||||
val isNew = createdAt > lastTime
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
if (parentBackgroundColor != null) {
|
||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
||||
} else {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
}
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
}
|
||||
|
||||
if (newBackgroundColor != backgroundColor.value) {
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
|
||||
if (newBackgroundColor != backgroundColor.value) {
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClickableNote(
|
||||
baseNote = baseNote,
|
||||
backgroundColor = backgroundColor,
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
showPopup = showPopup,
|
||||
nav = nav
|
||||
) {
|
||||
InnerChannelCardWithReactions(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerChannelCardWithReactions(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column() {
|
||||
RenderNoteRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderNoteRow(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (remember { baseNote.event }) {
|
||||
is LiveActivitiesEvent -> {
|
||||
RenderLiveActivityThumb(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderLiveActivityThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
|
||||
|
||||
val eventUpdates by baseNote.live().metadata.observeAsState()
|
||||
|
||||
val media = remember(eventUpdates) { noteEvent.streaming() }
|
||||
val cover by remember(eventUpdates) {
|
||||
derivedStateOf {
|
||||
noteEvent.image()?.ifBlank { null }
|
||||
}
|
||||
}
|
||||
val subject = remember(eventUpdates) { noteEvent.title()?.ifBlank { null } }
|
||||
val content = remember(eventUpdates) { noteEvent.summary() }
|
||||
val participants = remember(eventUpdates) { noteEvent.participants() }
|
||||
val status = remember(eventUpdates) { noteEvent.status() }
|
||||
|
||||
var isOnline by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = media) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newIsOnline = OnlineChecker.isOnline(media)
|
||||
if (isOnline != newIsOnline) {
|
||||
isOnline = newIsOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var participantUsers by remember {
|
||||
mutableStateOf<ImmutableList<User>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = eventUpdates) {
|
||||
launch(Dispatchers.IO) {
|
||||
val channel = LocalCache.getChannelIfExists(baseNote.idHex)
|
||||
val repliesByFollows = channel?.notes?.mapNotNull { it.value.author?.pubkeyHex }?.toSet() ?: emptySet()
|
||||
|
||||
val zappers = baseNote.publicZapAuthors()
|
||||
val likeAuthors = baseNote.reactionAuthors()
|
||||
|
||||
val allParticipants = (repliesByFollows + zappers + likeAuthors).filter { accountViewModel.isFollowing(it) }
|
||||
|
||||
val newParticipantUsers = (
|
||||
participants.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(part.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} + allParticipants.mapNotNull {
|
||||
if (it != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(it)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
).toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = TopEnd,
|
||||
modifier = Modifier
|
||||
.aspectRatio(ratio = 16f / 9f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
cover?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
} ?: run {
|
||||
baseNote.author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
Crossfade(targetState = status) {
|
||||
when (it) {
|
||||
STATUS_LIVE -> {
|
||||
if (media.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else if (isOnline) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
OfflineFlag()
|
||||
}
|
||||
}
|
||||
STATUS_ENDED -> {
|
||||
EndedFlag()
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
ScheduledFlag()
|
||||
}
|
||||
else -> {
|
||||
EndedFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp).align(BottomStart)) {
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
FlowRow() {
|
||||
participantUsers.forEach {
|
||||
ClickableUserPicture(it, Size35dp, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChannelHeader(
|
||||
channelHex = remember { baseNote.idHex },
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
showFlag = false,
|
||||
modifier = remember {
|
||||
Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayAuthorBanner(author: User) {
|
||||
val picture by author.live().metadata.map {
|
||||
it.user.info?.banner?.ifBlank { null } ?: it.user.info?.picture?.ifBlank { null }
|
||||
}.observeAsState()
|
||||
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -40,6 +42,7 @@ 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
|
||||
@@ -51,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
|
||||
@@ -63,11 +73,9 @@ fun ChatroomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
|
||||
val isBlank = remember(noteState) {
|
||||
noteState?.note?.event == null
|
||||
}
|
||||
val isBlank by baseNote.live().metadata.map {
|
||||
it.note.event == null
|
||||
}.observeAsState(baseNote.event == null)
|
||||
|
||||
if (isBlank) {
|
||||
BlankNote(Modifier)
|
||||
@@ -82,7 +90,7 @@ fun ChatroomComposeChannelOrUser(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val channelHex by remember {
|
||||
val channelHex by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
baseNote.channelHex()
|
||||
}
|
||||
@@ -101,15 +109,23 @@ private fun ChatroomDirectMessage(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val userRoomHex by remember {
|
||||
val userRoomHex by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
userRoomHex?.let {
|
||||
LoadUser(it) { user ->
|
||||
UserRoomCompose(baseNote, user, accountViewModel, nav)
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,19 +150,19 @@ private fun ChannelRoomCompose(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val authorState by note.author!!.live().metadata.observeAsState()
|
||||
val authorName = remember(authorState) {
|
||||
val authorName = remember(note, authorState) {
|
||||
authorState?.user?.toBestDisplayName()
|
||||
}
|
||||
|
||||
val chanHex = remember { channel.idHex }
|
||||
|
||||
val channelState by channel.live.observeAsState()
|
||||
val channelPicture by remember(channelState) {
|
||||
val channelPicture by remember(note, channelState) {
|
||||
derivedStateOf {
|
||||
channel.profilePicture()
|
||||
}
|
||||
}
|
||||
val channelName by remember(channelState) {
|
||||
val channelName by remember(note, channelState) {
|
||||
derivedStateOf {
|
||||
channel.toBestDisplayName()
|
||||
}
|
||||
@@ -166,7 +182,7 @@ private fun ChannelRoomCompose(
|
||||
noteEvent?.content()
|
||||
}
|
||||
|
||||
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages.value != newHasNewMessages) {
|
||||
@@ -227,12 +243,24 @@ private fun UserRoomCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var hasNewMessages = 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.value != newHasNewMessages) {
|
||||
hasNewMessages.value = newHasNewMessages
|
||||
@@ -248,8 +276,8 @@ private fun UserRoomCompose(
|
||||
)
|
||||
},
|
||||
channelTitle = { UsernameDisplay(user, it) },
|
||||
channelLastTime = remember(note) { note.createdAt() },
|
||||
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { nav(route) }
|
||||
)
|
||||
@@ -275,7 +303,7 @@ 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))
|
||||
}
|
||||
@@ -288,9 +316,7 @@ fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
user?.let {
|
||||
content(it)
|
||||
}
|
||||
content(user)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -335,84 +361,92 @@ fun ChannelName(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
|
||||
Row(
|
||||
modifier = remember { Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) }
|
||||
) {
|
||||
Column(remember { Modifier.width(Size55dp) }) {
|
||||
Row(modifier = ChatHeadlineBorders) {
|
||||
Column(Size55Modifier) {
|
||||
channelPicture()
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = remember { Modifier.padding(start = 10.dp).fillMaxWidth() },
|
||||
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: MutableState<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 = remember { Modifier.weight(1f) }
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.referenced_event_not_found),
|
||||
color = MaterialTheme.colors.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = remember { 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.value) {
|
||||
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
|
||||
@@ -431,6 +465,7 @@ fun NewItemsBubble() {
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.Center)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
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.interaction.MutableInteractionSource
|
||||
@@ -27,7 +29,6 @@ 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
|
||||
@@ -40,7 +41,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
@@ -50,9 +50,9 @@ 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
|
||||
@@ -71,24 +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.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
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)
|
||||
@@ -102,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,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 {
|
||||
@@ -320,7 +324,9 @@ fun NormalChatNote(
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize.value = it
|
||||
if (availableBubbleSize.value != it.width) {
|
||||
availableBubbleSize.value = it.width
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
@@ -362,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,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(
|
||||
@@ -458,7 +466,7 @@ private fun RenderReplyRow(
|
||||
) {
|
||||
val hasReply by remember {
|
||||
derivedStateOf {
|
||||
innerQuote && note.replyTo?.lastOrNull() != null
|
||||
!innerQuote && note.replyTo?.lastOrNull() != null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,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()
|
||||
}
|
||||
@@ -557,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -799,52 +807,61 @@ 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(
|
||||
@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,
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +923,7 @@ private fun RenderRelayIcon(iconUrl: String) {
|
||||
Modifier
|
||||
.size(Size13dp)
|
||||
.clip(shape = CircleShape)
|
||||
.drawBehind { drawRect(backgroundColor) }
|
||||
.background(backgroundColor)
|
||||
}
|
||||
|
||||
RobohashFallbackAsyncImage(
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -70,11 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
|
||||
|
||||
val columnModifier = remember(backgroundColor.value) {
|
||||
Modifier
|
||||
.drawBehind {
|
||||
drawRect(
|
||||
backgroundColor.value
|
||||
)
|
||||
}
|
||||
.background(backgroundColor.value)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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
|
||||
@@ -10,7 +12,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
|
||||
@@ -35,37 +36,51 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.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 }
|
||||
@@ -90,16 +105,16 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
if (backgroundColor.value != newBackgroundColor) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val columnModifier = remember(backgroundColor.value) {
|
||||
Modifier
|
||||
.drawBehind {
|
||||
drawRect(backgroundColor.value)
|
||||
}
|
||||
.background(backgroundColor.value)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
@@ -117,32 +132,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 } }
|
||||
@@ -153,17 +175,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,39 +202,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.firstFullChar()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -211,7 +226,7 @@ fun RenderLikeGallery(
|
||||
}
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel)
|
||||
AuthorGallery(likeEvents, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,21 +248,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)
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -255,21 +266,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),
|
||||
@@ -283,7 +287,7 @@ fun RenderBoostGallery(
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel)
|
||||
AuthorGallery(boostEvents, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,15 +299,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?,
|
||||
@@ -312,12 +328,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(
|
||||
@@ -330,29 +345,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,
|
||||
@@ -362,91 +392,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)
|
||||
.background(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,14 +481,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -470,100 +496,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
|
||||
) {
|
||||
val myBoxModifier = remember {
|
||||
Modifier.size(size)
|
||||
}
|
||||
|
||||
Box(myBoxModifier, contentAlignment = Alignment.TopEnd) {
|
||||
WatchMetadataAndRenderPictureWithFollwingMark(author, size, pictureModifier, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchMetadataAndRenderPictureWithFollwingMark(
|
||||
author: User,
|
||||
size: Dp,
|
||||
pictureModifier: 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
|
||||
}
|
||||
}
|
||||
|
||||
PictureAndFollowingMark(
|
||||
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
|
||||
@@ -109,20 +111,12 @@ 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 {
|
||||
Crossfade(targetState = nip05, modifier = columnModifier) {
|
||||
if (it != null) {
|
||||
DisplayNIP05Line(it, baseUser, columnModifier)
|
||||
}
|
||||
}
|
||||
@@ -131,9 +125,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,12 +141,17 @@ private fun DisplayNIP05(
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val (user, domain) = remember(nip05) {
|
||||
nip05.split("@")
|
||||
val parts = nip05.split("@")
|
||||
if (parts.size == 1) {
|
||||
listOf("_", parts[0])
|
||||
} else {
|
||||
listOf(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(user),
|
||||
text = remember(nip05) { AnnotatedString(user) },
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -160,7 +161,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,
|
||||
@@ -181,7 +182,7 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>) {
|
||||
)
|
||||
} else if (nip05Verified.value == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
painter = painterResource(R.drawable.ic_verified_transparent),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05.copy(0.52f),
|
||||
modifier = Modifier
|
||||
@@ -205,7 +206,7 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
if (nip05.split("@").size <= 2) {
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05Verified == null) {
|
||||
@@ -217,7 +218,7 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
painter = painterResource(R.drawable.ic_verified_transparent),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05,
|
||||
modifier = Modifier.size(16.dp)
|
||||
@@ -233,9 +234,18 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
|
||||
var domainPadStart = 5.dp
|
||||
|
||||
if (nip05.split("@")[0] != "_") {
|
||||
val (user, domain) = remember(nip05) {
|
||||
val parts = nip05.split("@")
|
||||
if (parts.size == 1) {
|
||||
listOf("_", parts[0])
|
||||
} else {
|
||||
listOf(parts[0], parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0] + "@"),
|
||||
text = remember { AnnotatedString(user + "@") },
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -244,7 +254,7 @@ fun DisplayNip05ProfileStatus(user: User) {
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
text = AnnotatedString(domain),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
|
||||
|
||||
@@ -7,5 +7,6 @@ fun ByteArray.toShortenHex(): String {
|
||||
}
|
||||
|
||||
fun String.toShortenHex(): String {
|
||||
if (length <= 16) return this
|
||||
return replaceRange(8, length - 8, ":")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.with
|
||||
@@ -18,10 +25,10 @@ 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
@@ -50,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Alignment.Companion.CenterStart
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -64,6 +72,8 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MediatorLiveData
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
@@ -82,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeight
|
||||
@@ -107,6 +118,8 @@ import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Composable
|
||||
fun ReactionsRow(
|
||||
@@ -132,6 +145,7 @@ fun ReactionsRow(
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun InnerReactionRow(
|
||||
baseNote: Note,
|
||||
@@ -147,7 +161,7 @@ private fun InnerReactionRow(
|
||||
modifier = ReactionRowExpandButton
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ExpandButton(baseNote) {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions)
|
||||
}
|
||||
}
|
||||
@@ -158,45 +172,69 @@ private fun InnerReactionRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ReplyReactionWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ReplyReactionWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Reply: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
BoostWithDialog(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colors.placeholderText,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Boost: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Likes: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ViewCountReaction(
|
||||
note = baseNote,
|
||||
grayTint = MaterialTheme.colors.placeholderText,
|
||||
viewCountColorFilter = MaterialTheme.colors.placeholderTextColorFilter
|
||||
)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ViewCountReaction(
|
||||
note = baseNote,
|
||||
grayTint = MaterialTheme.colors.placeholderText,
|
||||
viewCountColorFilter = MaterialTheme.colors.placeholderTextColorFilter
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Views: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,13 +254,12 @@ private fun LoadAndDisplayZapraiser(
|
||||
|
||||
if (zapraiserAmount > 0) {
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
Box(
|
||||
modifier = remember {
|
||||
ReactionRowZapraiserSize
|
||||
.padding(start = if (showReactionDetail) Size75dp else Size0dp)
|
||||
},
|
||||
horizontalArrangement = Arrangement.Start
|
||||
contentAlignment = CenterStart
|
||||
) {
|
||||
RenderZapRaiser(baseNote, zapraiserAmount, wantsToSeeReactions.value, accountViewModel)
|
||||
}
|
||||
@@ -271,7 +308,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
modifier = ReactionRowZapraiserSize,
|
||||
modifier = Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp),
|
||||
color = color,
|
||||
progress = zapraiserProgress
|
||||
)
|
||||
@@ -291,29 +328,52 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft),
|
||||
modifier = NoSoTinyBorders,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
fontSize = Font14SP
|
||||
fontSize = Font14SP,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandButton(baseNote: Note, content: @Composable () -> Unit) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
|
||||
val hasReactions by baseNote.live().zaps.combineWith(
|
||||
liveData1 = baseNote.live().boosts,
|
||||
liveData2 = baseNote.live().reactions,
|
||||
block = { zapsState, boostsState, reactionsState ->
|
||||
zapsState?.note?.zaps?.isNotEmpty() == true ||
|
||||
boostsState?.note?.boosts?.isNotEmpty() == true ||
|
||||
reactionsState?.note?.reactions?.isNotEmpty() == true
|
||||
}
|
||||
).observeAsState(
|
||||
baseNote.zaps.isNotEmpty() ||
|
||||
baseNote.boosts.isNotEmpty() ||
|
||||
baseNote.reactions.isNotEmpty()
|
||||
)
|
||||
|
||||
val hasReactions by remember(zapsState, boostsState, reactionsState) {
|
||||
derivedStateOf {
|
||||
baseNote.zaps.isNotEmpty() ||
|
||||
baseNote.boosts.isNotEmpty() ||
|
||||
baseNote.reactions.isNotEmpty()
|
||||
Crossfade(targetState = hasReactions) {
|
||||
if (it) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasReactions) {
|
||||
content()
|
||||
fun <T, K, P, R> LiveData<T>.combineWith(
|
||||
liveData1: LiveData<K>,
|
||||
liveData2: LiveData<P>,
|
||||
block: (T?, K?, P?) -> R
|
||||
): LiveData<R> {
|
||||
val result = MediatorLiveData<R>()
|
||||
result.addSource(this) {
|
||||
result.value = block(this.value, liveData1.value, liveData2.value)
|
||||
}
|
||||
result.addSource(liveData1) {
|
||||
result.value = block(this.value, liveData1.value, liveData2.value)
|
||||
}
|
||||
result.addSource(liveData2) {
|
||||
result.value = block(this.value, liveData1.value, liveData2.value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -324,20 +384,22 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
|
||||
},
|
||||
modifier = Size20Modifier
|
||||
) {
|
||||
if (wantsToSeeReactions.value) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandLess,
|
||||
null,
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colors.subtleButton
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colors.subtleButton
|
||||
)
|
||||
Crossfade(targetState = wantsToSeeReactions.value) {
|
||||
if (it) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandLess,
|
||||
null,
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colors.subtleButton
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Size22Modifier,
|
||||
tint = MaterialTheme.colors.subtleButton
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +448,6 @@ private fun ReactionDetailGallery(
|
||||
if (hasBoostEvents) {
|
||||
RenderBoostGallery(
|
||||
boostEvents,
|
||||
backgroundColor,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
@@ -398,7 +459,6 @@ private fun ReactionDetailGallery(
|
||||
RenderLikeGallery(
|
||||
it.key,
|
||||
reactions,
|
||||
backgroundColor,
|
||||
nav,
|
||||
accountViewModel
|
||||
)
|
||||
@@ -461,16 +521,10 @@ fun ReplyReaction(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = iconButtonModifier,
|
||||
modifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
},
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
onPress()
|
||||
@@ -487,8 +541,8 @@ fun ReplyReaction(
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
contentDescription = null,
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
@@ -502,51 +556,57 @@ fun ReplyReaction(
|
||||
fun ReplyCounter(baseNote: Note, textColor: Color) {
|
||||
val repliesState by baseNote.live().replies.map {
|
||||
it.note.replies.size
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
}.observeAsState(baseNote.replies.size)
|
||||
|
||||
SlidingAnimation(repliesState ?: 0, textColor)
|
||||
SlidingAnimation(repliesState, textColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun SlidingAnimation(count: Int, textColor: Color) {
|
||||
@Composable
|
||||
private fun SlidingAnimation(baseCount: Int, textColor: Color) {
|
||||
AnimatedContent<Int>(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
slideInVertically { -it } with slideOutVertically { it }
|
||||
} else {
|
||||
slideInVertically { it } with slideOutVertically { -it }
|
||||
}
|
||||
}
|
||||
targetState = baseCount,
|
||||
transitionSpec = AnimatedContentScope<Int>::transitionSpec
|
||||
) { count ->
|
||||
Text(
|
||||
text = showCount(count),
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
modifier = HalfStartPadding
|
||||
)
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun <S> AnimatedContentScope<S>.transitionSpec(): ContentTransform {
|
||||
return slideAnimation
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
val slideAnimation: ContentTransform = slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + fadeIn(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) with slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + fadeOut(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TextCount(count: Int, textColor: Color) {
|
||||
Text(
|
||||
text = remember(count) { showCount(count) },
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
modifier = HalfStartPadding,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun SlidingAnimation(amount: String, textColor: Color) {
|
||||
AnimatedContent<String>(
|
||||
AnimatedContent(
|
||||
targetState = amount,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState) {
|
||||
slideInVertically { -it } with slideOutVertically { it }
|
||||
} else {
|
||||
slideInVertically { it } with slideOutVertically { -it }
|
||||
}
|
||||
}
|
||||
transitionSpec = AnimatedContentScope<String>::transitionSpec
|
||||
) { count ->
|
||||
Text(
|
||||
text = amount,
|
||||
text = count,
|
||||
fontSize = Font14SP,
|
||||
color = textColor,
|
||||
modifier = HalfStartPadding
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -632,7 +692,7 @@ fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountV
|
||||
fun BoostText(baseNote: Note, grayTint: Color) {
|
||||
val boostState by baseNote.live().boosts.map {
|
||||
it.note.boosts.size
|
||||
}.distinctUntilChanged().observeAsState(0)
|
||||
}.distinctUntilChanged().observeAsState(baseNote.boosts.size)
|
||||
|
||||
SlidingAnimation(boostState, grayTint)
|
||||
}
|
||||
@@ -721,15 +781,19 @@ fun LikeIcon(
|
||||
launch(Dispatchers.Default) {
|
||||
val newReactionType = reactionsState?.note?.isReactedBy(accountViewModel.userProfile())?.firstFullChar()
|
||||
if (reactionType != newReactionType) {
|
||||
reactionType = newReactionType
|
||||
launch(Dispatchers.Main) {
|
||||
reactionType = newReactionType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reactionType != null) {
|
||||
RenderReactionType(reactionType!!, iconSize, iconFontSize)
|
||||
} else {
|
||||
RenderLikeIcon(iconSize, grayTint)
|
||||
Crossfade(targetState = reactionType) {
|
||||
if (it != null) {
|
||||
RenderReactionType(it, iconSize, iconFontSize)
|
||||
} else {
|
||||
RenderLikeIcon(iconSize, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -780,14 +844,16 @@ fun LikeText(baseNote: Note, grayTint: Color) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
var reactionsCount by remember(baseNote) {
|
||||
mutableStateOf(0)
|
||||
mutableStateOf(baseNote.reactions.size)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newReactionsCount = reactionsState?.note?.countReactions() ?: 0
|
||||
if (reactionsCount != newReactionsCount) {
|
||||
reactionsCount = newReactionsCount
|
||||
launch(Dispatchers.Main) {
|
||||
reactionsCount = newReactionsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -919,7 +985,7 @@ fun ZapReaction(
|
||||
}
|
||||
|
||||
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
|
||||
Spacer(Modifier.width(3.dp))
|
||||
Spacer(ModifierWidth3dp)
|
||||
|
||||
CircularProgressIndicator(
|
||||
progress = animateFloatAsState(
|
||||
@@ -1016,27 +1082,31 @@ private fun ZapIcon(
|
||||
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(it)
|
||||
|
||||
if (wasZappedByLoggedInUser != newWasZapped) {
|
||||
wasZappedByLoggedInUser = newWasZapped
|
||||
launch(Dispatchers.Main) {
|
||||
wasZappedByLoggedInUser = newWasZapped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wasZappedByLoggedInUser) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = grayTint
|
||||
)
|
||||
Crossfade(targetState = wasZappedByLoggedInUser) {
|
||||
if (it) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,7 +1125,9 @@ private fun ZapAmountText(
|
||||
zapsState?.note?.let {
|
||||
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(it))
|
||||
if (newZapAmount != zapAmountTxt) {
|
||||
zapAmountTxt = newZapAmount
|
||||
launch(Dispatchers.Main) {
|
||||
zapAmountTxt = newZapAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -50,6 +49,9 @@ import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -82,7 +84,7 @@ fun UserReactionsRow(
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
@@ -163,6 +165,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
fun today() = sdf.format(LocalDateTime.now())
|
||||
|
||||
private suspend fun initializeSuspend() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val currentUser = user.pubkeyHex
|
||||
|
||||
val reactions = mutableMapOf<String, Int>()
|
||||
@@ -212,6 +216,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
|
||||
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val currentUser = user.pubkeyHex
|
||||
|
||||
val reactions = this._reactions.value.toMutableMap()
|
||||
@@ -268,6 +274,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun refreshChartModel() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val day = 24 * 60 * 60L
|
||||
val now = LocalDateTime.now()
|
||||
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
|
||||
@@ -337,11 +345,11 @@ fun UserReplyReaction(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = RoyalBlue
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
showCounts,
|
||||
@@ -359,11 +367,11 @@ fun UserBoostReaction(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = Size24Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
showCounts,
|
||||
@@ -381,14 +389,14 @@ fun UserLikeReaction(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Size20Modifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Text(
|
||||
showCounts,
|
||||
text = showCounts,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
@@ -401,7 +409,7 @@ fun UserZapReaction(
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(24.dp),
|
||||
modifier = Size24Modifier,
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -14,60 +16,55 @@ 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.platform.LocalContext
|
||||
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.distinctUntilChanged
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
@Composable
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
|
||||
val authorState by baseNote.live().metadata.map {
|
||||
it.note.author
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
}.observeAsState(baseNote.author)
|
||||
|
||||
authorState?.let {
|
||||
UsernameDisplay(it, weight)
|
||||
Crossfade(targetState = authorState, modifier = weight) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, weight, showPlayButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
|
||||
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, showPlayButton)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, weight)
|
||||
}
|
||||
}
|
||||
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -76,14 +73,15 @@ private fun UserNameDisplay(
|
||||
bestDisplayName: String?,
|
||||
npubDisplay: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
|
||||
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier)
|
||||
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
|
||||
} else if (bestDisplayName != null) {
|
||||
UserDisplay(bestDisplayName, tags, modifier)
|
||||
UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
|
||||
} else if (bestUserName != null) {
|
||||
UserDisplay(bestUserName, tags, modifier)
|
||||
UserDisplay(bestUserName, tags, modifier, showPlayButton)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, modifier)
|
||||
}
|
||||
@@ -104,18 +102,23 @@ private fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
|
||||
private fun UserDisplay(
|
||||
bestDisplayName: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
if (showPlayButton) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -123,24 +126,29 @@ private fun UserAndUsernameDisplay(
|
||||
bestDisplayName: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
bestUserName: String,
|
||||
modifier: Modifier
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
) {
|
||||
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, verticalAlignment = Alignment.CenterVertically) {
|
||||
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
|
||||
)
|
||||
if (showPlayButton) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DrawPlayName(bestDisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -19,10 +20,12 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -35,6 +38,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 +110,7 @@ private fun RenderZapNote(
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
|
||||
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
|
||||
|
||||
Column(
|
||||
modifier = remember {
|
||||
@@ -193,6 +197,7 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
var isFollowing by remember { mutableStateOf(false) }
|
||||
val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState()
|
||||
@@ -209,9 +214,41 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
}
|
||||
|
||||
if (isFollowing) {
|
||||
UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.unfollow(baseAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.follow(baseAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,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.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -60,9 +59,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.drawBehind {
|
||||
drawRect(backgroundColor.value)
|
||||
}
|
||||
.background(backgroundColor.value)
|
||||
.clickable {
|
||||
nav("User/${zapSetCard.user.pubkeyHex}")
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
|
||||
singleList.chunked(50).map { chunk ->
|
||||
singleList.chunked(30).map { chunk ->
|
||||
MultiSetCard(
|
||||
baseNote,
|
||||
boostsInCard.filter { it in chunk }.toImmutableList(),
|
||||
@@ -235,9 +235,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
||||
|
||||
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
|
||||
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded && lastFeedKey == localFilter.feedKey()) {
|
||||
val filteredNewList = localFilter.applyFilter(newItems)
|
||||
|
||||
if (filteredNewList.isEmpty()) return
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeLiveActivitiesFeedFilter
|
||||
@@ -59,13 +59,6 @@ class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedVie
|
||||
}
|
||||
}
|
||||
|
||||
class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
|
||||
return NostrGlobalFeedViewModel(account) as NostrGlobalFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
|
||||
@@ -73,6 +66,15 @@ class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrDiscoverFeedViewModel(val account: Account) : FeedViewModel(DiscoverFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrDiscoverFeedViewModel : ViewModel> create(modelClass: Class<NostrDiscoverFeedViewModel>): NostrDiscoverFeedViewModel {
|
||||
return NostrDiscoverFeedViewModel(account) as NostrDiscoverFeedViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
|
||||
class Factory(val noteId: String) : ViewModelProvider.Factory {
|
||||
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
|
||||
@@ -81,11 +83,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 {
|
||||
@@ -174,8 +203,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
fun refreshSuspended() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val notes = localFilter.loadTop().toImmutableList()
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
val notes = localFilter.loadTop().toImmutableList()
|
||||
|
||||
val oldNotesState = _feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
@@ -204,16 +233,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
|
||||
|
||||
fun refreshFromOldState(newItems: Set<Note>) {
|
||||
val oldNotesState = _feedContent.value
|
||||
if (localFilter is AdditiveFeedFilter) {
|
||||
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
} else if (oldNotesState is FeedState.Empty) {
|
||||
val newList = localFilter.updateListWith(emptyList(), newItems.toSet()).toImmutableList()
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
if (newList.isNotEmpty()) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ object ScrollStateKeys {
|
||||
const val GLOBAL_SCREEN = "Global"
|
||||
const val NOTIFICATION_SCREEN = "Notifications"
|
||||
const val VIDEO_SCREEN = "Video"
|
||||
const val DISCOVER_SCREEN = "Discover"
|
||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
@@ -21,6 +22,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
|
||||
@@ -57,6 +61,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)
|
||||
}
|
||||
@@ -233,6 +242,10 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
return account.userProfile().isFollowingCached(user)
|
||||
}
|
||||
|
||||
fun isFollowing(user: HexKey): Boolean {
|
||||
return account.userProfile().isFollowingCached(user)
|
||||
}
|
||||
|
||||
val hideDeleteRequestDialog: Boolean
|
||||
get() = account.hideDeleteRequestDialog
|
||||
|
||||
@@ -263,6 +276,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
|
||||
|
||||
@@ -54,11 +54,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
@@ -90,10 +91,12 @@ 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 +119,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) {
|
||||
@@ -514,11 +517,12 @@ fun ChannelHeader(
|
||||
channelHex: String,
|
||||
showVideo: Boolean,
|
||||
showBottomDiviser: Boolean,
|
||||
showFlag: Boolean = true,
|
||||
modifier: Modifier = StdPadding,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var baseChannel by remember { mutableStateOf<Channel?>(LocalCache.channels[channelHex]) }
|
||||
var baseChannel by remember { mutableStateOf(LocalCache.channels[channelHex]) }
|
||||
|
||||
if (baseChannel == null) {
|
||||
LaunchedEffect(key1 = channelHex) {
|
||||
@@ -533,6 +537,7 @@ fun ChannelHeader(
|
||||
it,
|
||||
showVideo,
|
||||
showBottomDiviser,
|
||||
showFlag,
|
||||
modifier,
|
||||
accountViewModel,
|
||||
nav
|
||||
@@ -545,44 +550,76 @@ fun ChannelHeader(
|
||||
baseChannel: Channel,
|
||||
showVideo: Boolean,
|
||||
showBottomDiviser: Boolean,
|
||||
showFlag: Boolean = true,
|
||||
modifier: Modifier = StdPadding,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable {
|
||||
nav("Channel/${baseChannel.idHex}")
|
||||
}
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
nav("Channel/${baseChannel.idHex}")
|
||||
}
|
||||
}
|
||||
) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = remember(channelState) { channelState?.channel } ?: return
|
||||
|
||||
val streamingUrl by remember(channelState) {
|
||||
derivedStateOf {
|
||||
(channel as? LiveActivitiesChannel)?.info?.streaming()
|
||||
if (showVideo) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
streamingUrl?.let {
|
||||
CheckIfUrlIsOnline(it.url) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = remember { Modifier.heightIn(max = 300.dp) }
|
||||
) {
|
||||
ZoomableContentView(
|
||||
content = it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (streamingUrl != null && showVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
|
||||
VideoView(
|
||||
videoUri = streamingUrl!!,
|
||||
description = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channel.idHex,
|
||||
model = channel.profilePicture(),
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(Size35dp)
|
||||
.height(Size35dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
channel.creator?.let {
|
||||
UserPicture(
|
||||
user = it,
|
||||
size = Size35dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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
|
||||
@@ -592,7 +629,7 @@ fun ChannelHeader(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
channel.toBestDisplayName(),
|
||||
text = remember(channelState) { channel.toBestDisplayName() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -608,7 +645,7 @@ fun ChannelHeader(
|
||||
Text(
|
||||
text = summary,
|
||||
color = MaterialTheme.colors.placeholderText,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
@@ -619,14 +656,14 @@ fun ChannelHeader(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(Size35dp)
|
||||
.padding(bottom = 3.dp),
|
||||
.padding(start = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (channel is PublicChatChannel) {
|
||||
ChannelActionOptions(channel, accountViewModel, nav)
|
||||
}
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
LiveChannelActionOptions(channel, accountViewModel, nav)
|
||||
LiveChannelActionOptions(channel, showFlag, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,12 +712,13 @@ private fun ChannelActionOptions(
|
||||
@Composable
|
||||
private fun LiveChannelActionOptions(
|
||||
channel: LiveActivitiesChannel,
|
||||
showFlag: Boolean = true,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val isLive by remember(channel) {
|
||||
derivedStateOf {
|
||||
channel.info?.status() == "live"
|
||||
channel.info?.status() == STATUS_LIVE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,7 +727,7 @@ private fun LiveChannelActionOptions(
|
||||
}
|
||||
|
||||
note?.let {
|
||||
if (isLive) {
|
||||
if (showFlag && isLive) {
|
||||
LiveFlag()
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
@@ -701,15 +739,60 @@ private fun LiveChannelActionOptions(
|
||||
}
|
||||
|
||||
@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 {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.drawBehind { drawRect(Color.Red) }
|
||||
.background(Color.Red)
|
||||
.padding(horizontal = 5.dp)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EndedFlag() {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_ended_tag),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.background(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)
|
||||
.background(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)
|
||||
.background(Color.Black)
|
||||
.padding(horizontal = 5.dp)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,8 +71,6 @@ fun ChatroomListScreen(
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.start()
|
||||
knownFeedViewModel.invalidateData(true)
|
||||
newFeedViewModel.invalidateData(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelCardCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedError
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
|
||||
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
|
||||
@Composable
|
||||
fun DiscoverScreen(
|
||||
discoveryFeedViewModel: NostrDiscoverFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
WatchAccountForDiscoveryScreen(discoveryViewModel = discoveryFeedViewModel, accountViewModel = accountViewModel)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Discovery Start")
|
||||
NostrDiscoveryDataSource.start()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Discovery Stop")
|
||||
NostrDiscoveryDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
RefresheableView(discoveryFeedViewModel, true) {
|
||||
SaveableFeedState(discoveryFeedViewModel, scrollStateKey = ScrollStateKeys.DISCOVER_SCREEN) { listState ->
|
||||
RenderDiscoverFeed(discoveryFeedViewModel, accountViewModel, listState, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderDiscoverFeed(
|
||||
viewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
listState: LazyListState,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
viewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
viewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
DiscoverFeedLoaded(
|
||||
state,
|
||||
listState,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForDiscoveryScreen(discoveryViewModel: NostrDiscoverFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
LaunchedEffect(accountViewModel, accountState?.account?.defaultDiscoveryFollowList) {
|
||||
NostrDiscoveryDataSource.resetFilters()
|
||||
discoveryViewModel.checkKeysInvalidateDataAndSendToTop()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun DiscoverFeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
val defaultModifier = remember {
|
||||
Modifier.padding(10.dp).animateItemPlacement()
|
||||
}
|
||||
|
||||
ChannelCardCompose(
|
||||
item,
|
||||
modifier = defaultModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -18,12 +20,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
|
||||
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
|
||||
@@ -85,8 +89,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(
|
||||
@@ -123,7 +129,8 @@ private fun HashtagActionOptions(
|
||||
tag: String,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val userState by accountViewModel.userProfile().live().follows.observeAsState()
|
||||
val isFollowingTag by remember(userState) {
|
||||
@@ -133,8 +140,40 @@ private fun HashtagActionOptions(
|
||||
}
|
||||
|
||||
if (isFollowingTag) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(tag) } }
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.unfollow(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.account.follow(tag) } })
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.follow(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.map
|
||||
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 +77,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,13 +190,11 @@ 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 = remember { item.idHex },
|
||||
showVideo = false,
|
||||
@@ -210,18 +211,30 @@ private fun FeedLoaded(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckIfOnline(note: Note, whenOnline: @Composable () -> Unit) {
|
||||
val noteState by note.live().metadata.observeAsState()
|
||||
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
|
||||
val url by note.live().metadata.map {
|
||||
(note.event as? LiveActivitiesEvent)?.streaming()
|
||||
}.observeAsState()
|
||||
|
||||
url?.let {
|
||||
CheckIfUrlIsOnline(it, whenOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) {
|
||||
var online by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = noteState) {
|
||||
LaunchedEffect(key1 = url) {
|
||||
launch(Dispatchers.IO) {
|
||||
online = checkIfOnline((note.event as? LiveActivitiesEvent)?.streaming())
|
||||
online = OnlineChecker.isOnline(url)
|
||||
}
|
||||
}
|
||||
|
||||
if (online) {
|
||||
whenOnline()
|
||||
Crossfade(targetState = online) {
|
||||
if (it) {
|
||||
whenOnline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +38,12 @@ import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.currentRoute
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedLiveActivitiesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
@@ -95,16 +94,16 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
factory = NostrHomeFeedLiveActivitiesViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex + "NostrGlobalFeedViewModel",
|
||||
factory = NostrGlobalFeedViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex + "NostrVideoFeedViewModel",
|
||||
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val discoveryFeedViewModel: NostrDiscoverFeedViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex + "NostrDiscoveryFeedViewModel",
|
||||
factory = NostrDiscoverFeedViewModel.Factory(accountViewModel.account)
|
||||
)
|
||||
|
||||
val notifFeedViewModel: NotificationViewModel = viewModel(
|
||||
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
|
||||
factory = NotificationViewModel.Factory(accountViewModel.account)
|
||||
@@ -140,12 +139,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
homeFeedViewModel.sendToTop()
|
||||
repliesFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Search.base -> {
|
||||
searchFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Video.base -> {
|
||||
videoFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Discover.base -> {
|
||||
discoveryFeedViewModel.sendToTop()
|
||||
}
|
||||
Route.Notification.base -> {
|
||||
notifFeedViewModel.invalidateDataAndSendToTop()
|
||||
}
|
||||
@@ -193,8 +192,8 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
liveActivitiesViewModel,
|
||||
knownFeedViewModel,
|
||||
newFeedViewModel,
|
||||
searchFeedViewModel,
|
||||
videoFeedViewModel,
|
||||
discoveryFeedViewModel,
|
||||
notifFeedViewModel,
|
||||
userReactionsStatsModel,
|
||||
navController,
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
@@ -76,9 +75,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
@@ -104,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Composable
|
||||
@@ -116,7 +111,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
|
||||
if (userBase == null) {
|
||||
LaunchedEffect(userId) {
|
||||
// waits to resolve.
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
|
||||
if (newUserBase != userBase) {
|
||||
userBase = newUserBase
|
||||
@@ -166,33 +161,66 @@ fun PrepareViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (
|
||||
)
|
||||
)
|
||||
|
||||
val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel(
|
||||
key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel",
|
||||
factory = NostrUserProfileNewThreadsFeedViewModel.Factory(
|
||||
baseUser,
|
||||
accountViewModel.account
|
||||
)
|
||||
)
|
||||
|
||||
val repliesViewModel: NostrUserProfileConversationsFeedViewModel = viewModel(
|
||||
key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel",
|
||||
factory = NostrUserProfileConversationsFeedViewModel.Factory(
|
||||
baseUser,
|
||||
accountViewModel.account
|
||||
)
|
||||
)
|
||||
|
||||
val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel(
|
||||
key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel",
|
||||
factory = NostrUserProfileBookmarksFeedViewModel.Factory(
|
||||
baseUser,
|
||||
accountViewModel.account
|
||||
)
|
||||
)
|
||||
|
||||
val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = viewModel(
|
||||
key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel",
|
||||
factory = NostrUserProfileReportFeedViewModel.Factory(
|
||||
baseUser
|
||||
)
|
||||
)
|
||||
|
||||
ProfileScreen(
|
||||
baseUser = baseUser,
|
||||
threadsViewModel,
|
||||
repliesViewModel,
|
||||
followsFeedViewModel,
|
||||
followersFeedViewModel,
|
||||
appRecommendations,
|
||||
zapFeedViewModel,
|
||||
bookmarksFeedViewModel,
|
||||
reportsFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
baseUser: User,
|
||||
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
|
||||
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
|
||||
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
|
||||
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
|
||||
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
|
||||
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
|
||||
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
|
||||
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
UserProfileNewThreadFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
|
||||
UserProfileConversationsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
|
||||
UserProfileReportsFeedFilter.loadUserProfile(baseUser)
|
||||
UserProfileBookmarksFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
|
||||
|
||||
NostrUserProfileDataSource.loadUserProfile(baseUser)
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
@@ -224,16 +252,42 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var columnSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
RenderSurface(
|
||||
baseUser,
|
||||
threadsViewModel,
|
||||
repliesViewModel,
|
||||
appRecommendations,
|
||||
followsFeedViewModel,
|
||||
followersFeedViewModel,
|
||||
zapFeedViewModel,
|
||||
bookmarksFeedViewModel,
|
||||
reportsFeedViewModel,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderSurface(
|
||||
baseUser: User,
|
||||
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
|
||||
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
|
||||
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
|
||||
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
|
||||
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
|
||||
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
|
||||
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
|
||||
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colors.background
|
||||
) {
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
var columnSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -242,58 +296,110 @@ fun ProfileScreen(
|
||||
columnSize = it
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// When scrolling vertically, scroll the container first.
|
||||
return if (available.y < 0 && scrollState.canScrollForward) {
|
||||
coroutineScope.launch {
|
||||
scrollState.scrollBy(-available.y)
|
||||
}
|
||||
Offset(0f, available.y)
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
})
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column() {
|
||||
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
|
||||
ScrollableTabRow(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
edgePadding = 8.dp,
|
||||
modifier = Modifier.onSizeChanged {
|
||||
tabsSize = it
|
||||
}
|
||||
) {
|
||||
CreateAndRenderTabs(baseUser, pagerState)
|
||||
}
|
||||
HorizontalPager(
|
||||
pageCount = 8,
|
||||
state = pagerState,
|
||||
modifier = with(LocalDensity.current) {
|
||||
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
||||
}
|
||||
) { page ->
|
||||
CreateAndRenderPages(
|
||||
page,
|
||||
baseUser,
|
||||
followsFeedViewModel,
|
||||
followersFeedViewModel,
|
||||
zapFeedViewModel,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val tabRowModifier = remember {
|
||||
Modifier.onSizeChanged {
|
||||
tabsSize = it
|
||||
}
|
||||
}
|
||||
|
||||
val pagerModifier = with(LocalDensity.current) {
|
||||
Modifier.height((columnSize.height - tabsSize.height).toDp())
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.nestedScroll(object : NestedScrollConnection {
|
||||
override fun onPreScroll(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// When scrolling vertically, scroll the container first.
|
||||
return if (available.y < 0 && scrollState.canScrollForward) {
|
||||
coroutineScope.launch {
|
||||
scrollState.scrollBy(-available.y)
|
||||
}
|
||||
Offset(0f, available.y)
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
})
|
||||
.fillMaxHeight()
|
||||
}
|
||||
) {
|
||||
RenderScreen(
|
||||
baseUser,
|
||||
pagerState,
|
||||
tabRowModifier,
|
||||
pagerModifier,
|
||||
threadsViewModel,
|
||||
repliesViewModel,
|
||||
appRecommendations,
|
||||
followsFeedViewModel,
|
||||
followersFeedViewModel,
|
||||
zapFeedViewModel,
|
||||
bookmarksFeedViewModel,
|
||||
reportsFeedViewModel,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun RenderScreen(
|
||||
baseUser: User,
|
||||
pagerState: PagerState,
|
||||
tabRowModifier: Modifier,
|
||||
pagerModifier: Modifier,
|
||||
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
|
||||
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
|
||||
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
|
||||
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
|
||||
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
|
||||
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
|
||||
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
|
||||
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Column() {
|
||||
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
|
||||
ScrollableTabRow(
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
edgePadding = 8.dp,
|
||||
modifier = tabRowModifier
|
||||
) {
|
||||
CreateAndRenderTabs(baseUser, pagerState)
|
||||
}
|
||||
HorizontalPager(
|
||||
pageCount = 9,
|
||||
state = pagerState,
|
||||
modifier = pagerModifier
|
||||
) { page ->
|
||||
CreateAndRenderPages(
|
||||
page,
|
||||
baseUser,
|
||||
threadsViewModel,
|
||||
repliesViewModel,
|
||||
followsFeedViewModel,
|
||||
followersFeedViewModel,
|
||||
zapFeedViewModel,
|
||||
bookmarksFeedViewModel,
|
||||
reportsFeedViewModel,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,21 +408,26 @@ fun ProfileScreen(
|
||||
private fun CreateAndRenderPages(
|
||||
page: Int,
|
||||
baseUser: User,
|
||||
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
|
||||
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
|
||||
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
|
||||
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
|
||||
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
|
||||
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
|
||||
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
when (page) {
|
||||
0 -> TabNotesNewThreads(accountViewModel, nav)
|
||||
1 -> TabNotesConversations(accountViewModel, nav)
|
||||
0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav)
|
||||
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
|
||||
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
|
||||
3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
|
||||
4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
|
||||
5 -> TabBookmarks(baseUser, accountViewModel, nav)
|
||||
6 -> TabReports(baseUser, accountViewModel, nav)
|
||||
7 -> TabRelays(baseUser, accountViewModel, nav)
|
||||
5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
|
||||
6 -> TabFollowedTags(baseUser, accountViewModel, nav)
|
||||
7 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
|
||||
8 -> TabRelays(baseUser, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +446,7 @@ private fun CreateAndRenderTabs(
|
||||
{ FollowersTabHeader(baseUser) },
|
||||
{ ZapTabHeader(baseUser) },
|
||||
{ BookmarkTabHeader(baseUser) },
|
||||
{ FollowedTagsTabHeader(baseUser) },
|
||||
{ ReportsTabHeader(baseUser) },
|
||||
{ RelaysTabHeader(baseUser) }
|
||||
)
|
||||
@@ -368,8 +480,7 @@ private fun ReportsTabHeader(baseUser: User) {
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
launch(Dispatchers.IO) {
|
||||
UserProfileReportsFeedFilter.user = baseUser
|
||||
val newSize = UserProfileReportsFeedFilter.feed().size
|
||||
val newSize = UserProfileReportsFeedFilter(baseUser).feed().size
|
||||
|
||||
if (newSize != userReports) {
|
||||
userReports = newSize
|
||||
@@ -380,6 +491,25 @@ private fun ReportsTabHeader(baseUser: User) {
|
||||
Text(text = "$userReports ${stringResource(R.string.reports)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowedTagsTabHeader(baseUser: User) {
|
||||
var usertags by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = baseUser) {
|
||||
launch(Dispatchers.IO) {
|
||||
val contactList = baseUser?.latestContactList
|
||||
|
||||
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
|
||||
|
||||
if (newTags != usertags) {
|
||||
usertags = newTags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = "$usertags ${stringResource(R.string.followed_tags)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
@@ -568,6 +698,7 @@ private fun ProfileActions(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
|
||||
@@ -604,18 +735,60 @@ private fun ProfileActions(
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (isLoggedInFollowingUser) {
|
||||
UnfollowButton { scope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.unfollow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isUserFollowingLoggedIn) {
|
||||
FollowButton(
|
||||
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
|
||||
R.string.follow_back
|
||||
)
|
||||
FollowButton(R.string.follow_back) {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.follow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FollowButton(
|
||||
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
|
||||
R.string.follow
|
||||
)
|
||||
FollowButton(R.string.follow) {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.follow(baseUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1231,7 @@ private fun WatchAndRenderBadgeImage(
|
||||
pictureModifier
|
||||
.width(size)
|
||||
.height(size)
|
||||
.drawBehind { drawRect(bgColor) }
|
||||
.background(bgColor)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -1071,7 +1244,7 @@ private fun WatchAndRenderBadgeImage(
|
||||
.width(size)
|
||||
.height(size)
|
||||
.clip(shape = CircleShape)
|
||||
.drawBehind { drawRect(bgColor) }
|
||||
.background(bgColor)
|
||||
.run {
|
||||
if (onClick != null) {
|
||||
this.clickable(onClick = { onClick(eventId) })
|
||||
@@ -1126,9 +1299,7 @@ public fun DrawBanner(baseUser: User) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
|
||||
fun TabNotesNewThreads(feedViewModel: NostrUserProfileNewThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
@@ -1149,9 +1320,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
|
||||
fun TabNotesConversations(feedViewModel: NostrUserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
@@ -1172,9 +1341,28 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
fun TabFollowedTags(baseUser: User, account: AccountViewModel, nav: (String) -> Unit) {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
baseUser.latestContactList?.let {
|
||||
it.unverifiedFollowTagSet().forEach { hashtag ->
|
||||
HashtagHeader(
|
||||
tag = hashtag,
|
||||
account = account,
|
||||
onClick = {
|
||||
nav("Hashtag/$hashtag")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabBookmarks(feedViewModel: NostrUserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
@@ -1264,9 +1452,7 @@ private fun WatchZapsAndUpdateFeed(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val feedViewModel: NostrUserProfileReportFeedViewModel = viewModel()
|
||||
|
||||
fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
@@ -1396,7 +1582,7 @@ fun UnfollowButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowButton(onClick: () -> Unit, text: Int = R.string.follow) {
|
||||
fun FollowButton(text: Int = R.string.follow, onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
onClick = onClick,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -66,9 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -86,7 +83,6 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -97,33 +93,29 @@ fun SearchScreen(
|
||||
)
|
||||
)
|
||||
|
||||
SearchScreen(searchFeedViewModel, searchBarViewModel, accountViewModel, nav)
|
||||
SearchScreen(searchBarViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
WatchAccountForSearchScreen(searchFeedViewModel, accountViewModel)
|
||||
WatchAccountForSearchScreen(accountViewModel)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Global Start")
|
||||
NostrGlobalDataSource.start()
|
||||
println("Search Start")
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
searchFeedViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Global Stop")
|
||||
println("Search Stop")
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrGlobalDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,29 +125,19 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
SearchBar(searchBarViewModel, accountViewModel, nav)
|
||||
RefresheableFeedView(
|
||||
searchFeedViewModel,
|
||||
null,
|
||||
scrollStateKey = ScrollStateKeys.GLOBAL_SCREEN,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SearchBar(searchBarViewModel, listState)
|
||||
DisplaySearchResults(searchBarViewModel, listState, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
fun WatchAccountForSearchScreen(accountViewModel: AccountViewModel) {
|
||||
LaunchedEffect(accountViewModel) {
|
||||
launch(Dispatchers.IO) {
|
||||
NostrGlobalDataSource.resetFilters()
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
searchFeedViewModel.invalidateData(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,11 +210,9 @@ class SearchBarViewModel(val account: Account) : ViewModel() {
|
||||
@Composable
|
||||
private fun SearchBar(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
listState: LazyListState
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
val searchTextChanges = remember {
|
||||
@@ -283,8 +263,6 @@ private fun SearchBar(
|
||||
searchTextChanges.trySend(it)
|
||||
}
|
||||
}
|
||||
|
||||
DisplaySearchResults(listState, searchBarViewModel, nav, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -352,8 +330,8 @@ private fun SearchTextField(
|
||||
|
||||
@Composable
|
||||
private fun DisplaySearchResults(
|
||||
listState: LazyListState,
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
listState: LazyListState,
|
||||
nav: (String) -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.vitorpamplona.amethyst.ui.theme
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Shapes
|
||||
@@ -19,6 +21,8 @@ val Shapes = Shapes(
|
||||
large = RoundedCornerShape(0.dp)
|
||||
)
|
||||
|
||||
val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size
|
||||
|
||||
val SmallBorder = RoundedCornerShape(7.dp)
|
||||
val QuoteBorder = RoundedCornerShape(15.dp)
|
||||
val ButtonBorder = RoundedCornerShape(20.dp)
|
||||
@@ -39,6 +43,7 @@ val DoubleVertSpacer = Modifier.height(10.dp)
|
||||
val HalfDoubleVertSpacer = Modifier.height(7.dp)
|
||||
|
||||
val Size0dp = 0.dp
|
||||
val Size10dp = 10.dp
|
||||
val Size13dp = 13.dp
|
||||
val Size15dp = 15.dp
|
||||
val Size16dp = 16.dp
|
||||
@@ -46,6 +51,7 @@ val Size17dp = 17.dp
|
||||
val Size18dp = 18.dp
|
||||
val Size19dp = 19.dp
|
||||
val Size20dp = 20.dp
|
||||
val Size22dp = 22.dp
|
||||
val Size24dp = 24.dp
|
||||
val Size25dp = 25.dp
|
||||
val Size30dp = 30.dp
|
||||
@@ -55,14 +61,20 @@ val Size75dp = 75.dp
|
||||
|
||||
val HalfStartPadding = Modifier.padding(start = 5.dp)
|
||||
val StdStartPadding = Modifier.padding(start = 10.dp)
|
||||
val StdTopPadding = Modifier.padding(top = 10.dp)
|
||||
|
||||
val HalfPadding = Modifier.padding(5.dp)
|
||||
val StdPadding = Modifier.padding(10.dp)
|
||||
|
||||
val Size6Modifier = Modifier.size(6.dp)
|
||||
val Size10Modifier = Modifier.size(10.dp)
|
||||
val Size15Modifier = Modifier.size(15.dp)
|
||||
val Size18Modifier = Modifier.size(18.dp)
|
||||
val Size20Modifier = Modifier.size(20.dp)
|
||||
val Size22Modifier = Modifier.size(22.dp)
|
||||
val Size24Modifier = Modifier.size(24.dp)
|
||||
val Size30Modifier = Modifier.size(30.dp)
|
||||
val Size35Modifier = Modifier.size(35.dp)
|
||||
val Size55Modifier = Modifier.size(55.dp)
|
||||
|
||||
val TinyBorders = Modifier.padding(2.dp)
|
||||
@@ -71,11 +83,29 @@ val ReactionRowZapraiserSize = Modifier.defaultMinSize(minHeight = 4.dp).fillMax
|
||||
val ReactionRowExpandButton = Modifier.width(65.dp).padding(start = 31.dp)
|
||||
|
||||
val WidthAuthorPictureModifier = Modifier.width(55.dp)
|
||||
val WidthAuthorPictureModifierWithPadding = Modifier.width(65.dp)
|
||||
|
||||
val DiviserThickness = 0.25.dp
|
||||
val DividerThickness = 0.25.dp
|
||||
|
||||
val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
|
||||
val ReactionRowHeightChat = Modifier.height(25.dp)
|
||||
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth()
|
||||
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth()
|
||||
|
||||
val Height4dpModifier = Modifier.height(4.dp)
|
||||
|
||||
val AccountPictureModifier = Modifier.width(55.dp).height(55.dp).clip(shape = CircleShape)
|
||||
|
||||
val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp)
|
||||
val ShowMoreRelaysButtonIconModifier = Modifier.size(15.dp)
|
||||
val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp)
|
||||
|
||||
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)
|
||||
|
||||
val ModifierWidth3dp = Modifier.width(3.dp)
|
||||
|
||||
val NotificationIconModifier = Modifier.width(55.dp).padding(end = 5.dp)
|
||||
val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp)
|
||||
|
||||
val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp)
|
||||
val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -14,7 +16,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
@@ -25,9 +26,7 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultHeadingStyle
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultParagraphSpacing
|
||||
import com.vitorpamplona.amethyst.ui.components.richTextDefaults
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
|
||||
private val DarkColorPalette = darkColors(
|
||||
primary = Purple200,
|
||||
@@ -121,10 +120,60 @@ val LightImageModifier = Modifier
|
||||
QuoteBorder
|
||||
)
|
||||
|
||||
val MarkDownStyleOnDark = richTextDefaults.copy(
|
||||
val DarkProfile35dpModifier = Modifier
|
||||
.size(Size35dp)
|
||||
.clip(shape = CircleShape)
|
||||
|
||||
val LightProfile35dpModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = CircleShape)
|
||||
|
||||
val DarkReplyBorderModifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
DarkSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
|
||||
val LightReplyBorderModifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
LightSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
|
||||
val DarkInnerPostBorderModifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
DarkSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
|
||||
val LightInnerPostBorderModifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = QuoteBorder)
|
||||
.border(
|
||||
1.dp,
|
||||
LightSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
|
||||
val RichTextDefaults = RichTextStyle().resolveDefaults()
|
||||
|
||||
val MarkDownStyleOnDark = RichTextDefaults.copy(
|
||||
paragraphSpacing = DefaultParagraphSpacing,
|
||||
headingStyle = DefaultHeadingStyle,
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = Font14SP
|
||||
@@ -138,11 +187,9 @@ val MarkDownStyleOnDark = richTextDefaults.copy(
|
||||
DarkSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
.drawBehind {
|
||||
drawRect(DarkColorPalette.onSurface.copy(alpha = 0.05f))
|
||||
}
|
||||
.background(DarkColorPalette.onSurface.copy(alpha = 0.05f))
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
stringStyle = RichTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
color = DarkColorPalette.primary
|
||||
),
|
||||
@@ -154,10 +201,10 @@ val MarkDownStyleOnDark = richTextDefaults.copy(
|
||||
)
|
||||
)
|
||||
|
||||
val MarkDownStyleOnLight = richTextDefaults.copy(
|
||||
val MarkDownStyleOnLight = RichTextDefaults.copy(
|
||||
paragraphSpacing = DefaultParagraphSpacing,
|
||||
headingStyle = DefaultHeadingStyle,
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = Font14SP
|
||||
@@ -171,11 +218,9 @@ val MarkDownStyleOnLight = richTextDefaults.copy(
|
||||
LightSubtleBorder,
|
||||
QuoteBorder
|
||||
)
|
||||
.drawBehind {
|
||||
drawRect(LightColorPalette.onSurface.copy(alpha = 0.05f))
|
||||
}
|
||||
.background(DarkColorPalette.onSurface.copy(alpha = 0.05f))
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
stringStyle = RichTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
color = LightColorPalette.primary
|
||||
),
|
||||
@@ -243,6 +288,15 @@ val Colors.repostProfileBorder: Modifier
|
||||
val Colors.imageModifier: Modifier
|
||||
get() = if (isLight) LightImageModifier else DarkImageModifier
|
||||
|
||||
val Colors.profile35dpModifier: Modifier
|
||||
get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier
|
||||
|
||||
val Colors.replyModifier: Modifier
|
||||
get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier
|
||||
|
||||
val Colors.innerPostModifier: Modifier
|
||||
get() = if (isLight) LightInnerPostBorderModifier else DarkInnerPostBorderModifier
|
||||
|
||||
@Composable
|
||||
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
|
||||
val colors = if (darkTheme) {
|
||||
|
||||
@@ -4,8 +4,10 @@ import androidx.compose.material.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.halilibo.richtext.ui.HeadingStyle
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
@@ -29,5 +31,37 @@ val Typography = Typography(
|
||||
)
|
||||
|
||||
val Font14SP = 14.sp
|
||||
val Font17SP = 17.sp
|
||||
|
||||
val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em)
|
||||
|
||||
val DefaultParagraphSpacing: TextUnit = 12.sp
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_verified_transparent.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 5.1 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_verified_transparent.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |